fix: eliminate pull-to-refresh flicker by holding stale data during re-fetch

This commit is contained in:
2026-04-08 14:39:50 -04:00
parent 6c29b685e8
commit 3e888b6458
12 changed files with 70 additions and 14 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 —
/// 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);
}
@@ -254,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();
}
}
+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;
+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);
+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,
+1 -1
View File
@@ -68,7 +68,7 @@ class CalendarScreen extends ConsumerWidget {
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () async => ref.invalidate(calendarProvider),
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
child: _AgendaList(
events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
+1 -1
View File
@@ -30,7 +30,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
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(
onRefresh: () async => ref.invalidate(conversationsProvider),
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
@@ -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: [
+1 -1
View File
@@ -97,7 +97,7 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () async => ref.invalidate(newsProvider),
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+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),