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 — /// 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();
} }
} }
+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);
+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
@@ -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
@@ -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) {
@@ -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),