mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Show a page-level progress bar while paginating lists
Changing pages in the notifications or devices list gave no cue that the next page was loading. Render an indeterminate progress bar at the shell level, pinned flush against the bottom of the page body (above the nav bar on phones, the screen bottom on wide layouts), driven by a shared paginationLoading notifier the lists set while fetching. The pagination bar keeps disabling its buttons during the fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
52533f447c
commit
c949deedb6
@@ -13,6 +13,7 @@ import '../utils/oott_api.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/filter_selector.dart';
|
||||
import '../widgets/pagination_bar.dart';
|
||||
import '../widgets/pagination_progress.dart';
|
||||
import '../widgets/skeleton.dart';
|
||||
import 'device_list_filter.dart';
|
||||
import 'device_list_rows.dart';
|
||||
@@ -35,6 +36,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
DeviceFilter _filter = DeviceFilter.newDevices;
|
||||
List<Device> _devices = [];
|
||||
bool _isLoading = true;
|
||||
bool _isPaging = false;
|
||||
String? _error;
|
||||
final TextEditingController _ownerController = TextEditingController();
|
||||
DeviceType? _typeFilter;
|
||||
@@ -86,6 +88,8 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
_fetchToken?.cancel();
|
||||
_ownerController.dispose();
|
||||
_scrollController.dispose();
|
||||
// Clear any in-flight cue so it doesn't linger after leaving the page.
|
||||
paginationLoading.value = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -99,7 +103,14 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
|
||||
/// Fetches [page]. When [scrollToTop] is set, the list animates back to its
|
||||
/// first row, so changing pages always starts the new page from the top.
|
||||
Future<void> _fetchPage(int page, {bool scrollToTop = false}) async {
|
||||
/// When [paging] is set the current page stays visible and the pagination
|
||||
/// bar shows a progress cue; background refreshes leave [paging] false so
|
||||
/// they don't flash the bar.
|
||||
Future<void> _fetchPage(
|
||||
int page, {
|
||||
bool scrollToTop = false,
|
||||
bool paging = false,
|
||||
}) async {
|
||||
if (scrollToTop && _scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
@@ -112,8 +123,10 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
_fetchToken = token;
|
||||
setState(() {
|
||||
_isLoading = _devices.isEmpty;
|
||||
_isPaging = paging;
|
||||
_error = null;
|
||||
});
|
||||
paginationLoading.value = paging;
|
||||
try {
|
||||
bool? isRegistered;
|
||||
if (_filter == DeviceFilter.newDevices) isRegistered = false;
|
||||
@@ -135,14 +148,18 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
_hasNextPage = result.hasNextPage;
|
||||
_devices = result.items;
|
||||
_isLoading = false;
|
||||
_isPaging = false;
|
||||
});
|
||||
paginationLoading.value = false;
|
||||
} catch (e) {
|
||||
if (!mounted || token != _fetchToken) return;
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||
setState(() {
|
||||
_error = dioErrorToUserMessage(e);
|
||||
_isLoading = false;
|
||||
_isPaging = false;
|
||||
});
|
||||
paginationLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +354,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
||||
child: PaginationBar(
|
||||
currentPage: _currentPage,
|
||||
hasNextPage: _hasNextPage,
|
||||
isLoading: _isLoading,
|
||||
onPageChanged: (page) => _fetchPage(page, scrollToTop: true),
|
||||
isLoading: _isPaging,
|
||||
onPageChanged: (page) =>
|
||||
_fetchPage(page, scrollToTop: true, paging: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../utils/ui_snackbars.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/filter_selector.dart';
|
||||
import '../widgets/pagination_bar.dart';
|
||||
import '../widgets/pagination_progress.dart';
|
||||
import '../widgets/skeleton.dart';
|
||||
import 'notification_card.dart';
|
||||
|
||||
@@ -55,6 +56,7 @@ class _NotificationsListState extends State<NotificationsList>
|
||||
bool _didInitialFetch = false;
|
||||
List<oott_model.Notification> _items = [];
|
||||
bool _isLoading = false;
|
||||
bool _isPaging = false;
|
||||
|
||||
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
||||
? _phonePageSize
|
||||
@@ -124,12 +126,21 @@ class _NotificationsListState extends State<NotificationsList>
|
||||
_notificationTimer?.cancel();
|
||||
_fetchToken?.cancel();
|
||||
_scrollController.dispose();
|
||||
// Clear any in-flight cue so it doesn't linger after leaving the page.
|
||||
paginationLoading.value = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fetches [page]. When [scrollToTop] is set, the list animates back to its
|
||||
/// first item, so changing pages always starts the new page from the top.
|
||||
Future<void> _fetchPage(int page, {bool scrollToTop = false}) async {
|
||||
/// When [paging] is set the current page stays visible and the pagination
|
||||
/// bar shows a progress cue; background refreshes leave [paging] false so
|
||||
/// they don't flash the bar.
|
||||
Future<void> _fetchPage(
|
||||
int page, {
|
||||
bool scrollToTop = false,
|
||||
bool paging = false,
|
||||
}) async {
|
||||
if (scrollToTop && _scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
@@ -142,8 +153,10 @@ class _NotificationsListState extends State<NotificationsList>
|
||||
_fetchToken = token;
|
||||
setState(() {
|
||||
_isLoading = _items.isEmpty;
|
||||
_isPaging = paging;
|
||||
_error = null;
|
||||
});
|
||||
paginationLoading.value = paging;
|
||||
try {
|
||||
final result = await BackendAPI.instance.listNotifications(
|
||||
_filter.isNew,
|
||||
@@ -157,14 +170,18 @@ class _NotificationsListState extends State<NotificationsList>
|
||||
_hasNextPage = result.hasNextPage;
|
||||
_items = result.items;
|
||||
_isLoading = false;
|
||||
_isPaging = false;
|
||||
});
|
||||
paginationLoading.value = false;
|
||||
} catch (e) {
|
||||
if (!mounted || token != _fetchToken) return;
|
||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||
setState(() {
|
||||
_error = dioErrorToUserMessage(e);
|
||||
_isLoading = false;
|
||||
_isPaging = false;
|
||||
});
|
||||
paginationLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,8 +330,9 @@ class _NotificationsListState extends State<NotificationsList>
|
||||
child: PaginationBar(
|
||||
currentPage: _currentPage,
|
||||
hasNextPage: _hasNextPage,
|
||||
isLoading: _isLoading,
|
||||
onPageChanged: (page) => _fetchPage(page, scrollToTop: true),
|
||||
isLoading: _isPaging,
|
||||
onPageChanged: (page) =>
|
||||
_fetchPage(page, scrollToTop: true, paging: true),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'status/status_screen.dart';
|
||||
import 'theme/dimens.dart';
|
||||
import 'utils/pref_utils.dart';
|
||||
import 'widgets/offline_banner.dart';
|
||||
import 'widgets/pagination_progress.dart';
|
||||
|
||||
typedef _NavDest = ({IconData icon, IconData activeIcon, String label});
|
||||
|
||||
@@ -106,16 +107,20 @@ class MainShell extends StatelessWidget {
|
||||
if (width < Breakpoints.medium) {
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(context, selectedIndex),
|
||||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Insets.lg),
|
||||
child: child,
|
||||
// The overlay pins the pagination progress bar flush against the
|
||||
// bottom of the body, i.e. the top of the navigation bar below.
|
||||
body: PaginationProgressOverlay(
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Insets.lg),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: selectedIndex,
|
||||
@@ -161,16 +166,20 @@ class MainShell extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Insets.lg),
|
||||
child: child,
|
||||
// The overlay pins the pagination progress bar flush against
|
||||
// the very bottom of the content region (the screen bottom).
|
||||
child: PaginationProgressOverlay(
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Insets.lg),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -18,6 +18,9 @@ class PaginationBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final canGoBack = currentPage > 0 && !isLoading;
|
||||
final canGoForward = hasNextPage && !isLoading;
|
||||
// While a page change is in flight the buttons disable so the tap reads as
|
||||
// registered and double-taps are blocked; the progress cue itself is drawn
|
||||
// by the app shell at the bottom of the page body.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared flag that drives the page-level pagination progress bar. The lists set
|
||||
/// it while fetching a new page; the app shell renders the bar flush against the
|
||||
/// bottom of the page body (above the navigation bar on phones, the screen
|
||||
/// bottom on wider layouts) so the cue sits outside the page's content padding.
|
||||
final ValueNotifier<bool> paginationLoading = ValueNotifier<bool>(false);
|
||||
|
||||
/// Overlays [child] with an indeterminate progress bar pinned to the bottom of
|
||||
/// the available space, shown whenever [paginationLoading] is set. The bar is
|
||||
/// full width and flush with the bottom edge, so it reads as a page-level cue
|
||||
/// rather than part of the scrolling content.
|
||||
class PaginationProgressOverlay extends StatelessWidget {
|
||||
const PaginationProgressOverlay({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: paginationLoading,
|
||||
builder: (context, loading, _) => loading
|
||||
? const LinearProgressIndicator(minHeight: 4)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/theme/gruvbox_theme.dart';
|
||||
import 'package:frontend/widgets/pagination_bar.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child) => MaterialApp(
|
||||
theme: gruvboxDarkTheme,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
testWidgets('disables every navigation button while loading', (tester) async {
|
||||
var changedTo = -1;
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
PaginationBar(
|
||||
currentPage: 1,
|
||||
hasNextPage: true,
|
||||
isLoading: true,
|
||||
onPageChanged: (page) => changedTo = page,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Next page'));
|
||||
await tester.tap(find.byTooltip('Previous page'));
|
||||
await tester.tap(find.byTooltip('First page'));
|
||||
expect(changedTo, -1);
|
||||
});
|
||||
|
||||
testWidgets('enables navigation when idle', (tester) async {
|
||||
var changedTo = -1;
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
PaginationBar(
|
||||
currentPage: 1,
|
||||
hasNextPage: true,
|
||||
isLoading: false,
|
||||
onPageChanged: (page) => changedTo = page,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Next page'));
|
||||
expect(changedTo, 2);
|
||||
|
||||
await tester.tap(find.byTooltip('Previous page'));
|
||||
expect(changedTo, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/theme/gruvbox_theme.dart';
|
||||
import 'package:frontend/widgets/pagination_progress.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() => paginationLoading.value = false);
|
||||
|
||||
testWidgets('shows the bar only while paginationLoading is set', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: gruvboxDarkTheme,
|
||||
home: const Scaffold(
|
||||
body: PaginationProgressOverlay(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
|
||||
paginationLoading.value = true;
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
paginationLoading.value = false;
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pins the bar flush against the bottom edge', (tester) async {
|
||||
paginationLoading.value = true;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: gruvboxDarkTheme,
|
||||
home: const Scaffold(
|
||||
body: PaginationProgressOverlay(child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final overlayRect = tester.getRect(find.byType(PaginationProgressOverlay));
|
||||
final barRect = tester.getRect(find.byType(LinearProgressIndicator));
|
||||
expect(barRect.bottom, overlayRect.bottom);
|
||||
expect(barRect.left, overlayRect.left);
|
||||
expect(barRect.right, overlayRect.right);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user