mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Replace infinite-scroll notifications with button-based pagination
- Swap PagingController/PagedSliverList for explicit page state and Previous/Next icon buttons below the list - Fetch pageSize+1 items to detect the last page without a total-count API; pagination controls are hidden when there is only one page - Remove unused infinite_scroll_pagination dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2077f970f9
commit
85b86026cb
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- [ ] Change the notifications list so it has explicit paging (not infinite paging)
|
- [x] Change the notifications list so it has explicit paging (not infinite paging)
|
||||||
- [x] List recorded devices
|
- [x] List recorded devices
|
||||||
- [x] Register a device
|
- [x] Register a device
|
||||||
- [x] Forget a device
|
- [x] Forget a device
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
|
||||||
|
|
||||||
import '../model/notification.dart' as oott_model;
|
import '../model/notification.dart' as oott_model;
|
||||||
import '../utils/friendly_date_formatter.dart';
|
import '../utils/friendly_date_formatter.dart';
|
||||||
@@ -12,6 +11,7 @@ import '../widgets/device_summary_card.dart';
|
|||||||
import 'notification_card.dart';
|
import 'notification_card.dart';
|
||||||
|
|
||||||
const _twoColumnBreakpoint = 700.0;
|
const _twoColumnBreakpoint = 700.0;
|
||||||
|
const _pageSize = 5;
|
||||||
|
|
||||||
enum _NotificationFilter {
|
enum _NotificationFilter {
|
||||||
newOnly('New'),
|
newOnly('New'),
|
||||||
@@ -40,34 +40,49 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_NotificationFilter _filter = _NotificationFilter.newOnly;
|
_NotificationFilter _filter = _NotificationFilter.newOnly;
|
||||||
Timer? _notificationTimer;
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
late final _pagingController =
|
int _currentPage = 0;
|
||||||
PagingController<int, oott_model.Notification>(
|
List<oott_model.Notification> _items = [];
|
||||||
getNextPageKey: (state) => state.lastPageIsEmpty
|
bool _isLoading = false;
|
||||||
? null
|
bool _hasNextPage = false;
|
||||||
: (state.items == null ? 0 : state.items?.length),
|
|
||||||
fetchPage: (pageKey) =>
|
|
||||||
BackendAPI.instance.listNotifications(_filter.isNew, pageKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_fetchPage(0);
|
||||||
_notificationTimer = Timer.periodic(
|
_notificationTimer = Timer.periodic(
|
||||||
const Duration(minutes: 1),
|
const Duration(minutes: 1),
|
||||||
(_) => _pagingController.refresh(),
|
(_) => _fetchPage(_currentPage),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_pagingController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchPage(int page) async {
|
||||||
|
if (_isLoading) return;
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final results = await BackendAPI.instance
|
||||||
|
.listNotifications(_filter.isNew, page * _pageSize, limit: _pageSize + 1);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_currentPage = page;
|
||||||
|
_hasNextPage = results.length > _pageSize;
|
||||||
|
_items = _hasNextPage ? results.take(_pageSize).toList() : results;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _markAllAsRead(BuildContext context) async {
|
Future<void> _markAllAsRead(BuildContext context) async {
|
||||||
await BackendAPI.instance.markAllNotificationsAsRead();
|
await BackendAPI.instance.markAllNotificationsAsRead();
|
||||||
_pagingController.refresh();
|
_fetchPage(_currentPage);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
||||||
}
|
}
|
||||||
@@ -96,13 +111,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
'Event marked as ${read ? 'read' : 'unread'}',
|
'Event marked as ${read ? 'read' : 'unread'}',
|
||||||
);
|
);
|
||||||
if (_filter != _NotificationFilter.all) {
|
if (_filter != _NotificationFilter.all) {
|
||||||
_pagingController.value = _pagingController.value.filterItems(
|
setState(() => _items = _items.where((n) => n.id != item.id).toList());
|
||||||
(n) => n.id != item.id,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_pagingController.mapItems(
|
setState(
|
||||||
(n) => n.id == item.id ? n.copyWith(isNew: !read) : n,
|
() => _items = _items
|
||||||
|
.map((n) => n.id == item.id ? n.copyWith(isNew: !read) : n)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -112,21 +127,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint;
|
final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint;
|
||||||
return PagingListener(
|
return isTwoColumn
|
||||||
controller: _pagingController,
|
? _buildTwoColumn(context)
|
||||||
builder: (context, state, fetchNextPage) => isTwoColumn
|
: _buildSingleColumn(context);
|
||||||
? _buildTwoColumn(context, state, fetchNextPage)
|
|
||||||
: _buildSingleColumn(context, state, fetchNextPage),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTwoColumn(
|
Widget _buildTwoColumn(BuildContext context) {
|
||||||
BuildContext context,
|
|
||||||
PagingState<int, oott_model.Notification> state,
|
|
||||||
void Function() fetchNextPage,
|
|
||||||
) {
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -135,10 +143,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildNotificationsHeader(context, state),
|
_buildNotificationsHeader(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [_buildNotificationSliver(state, fetchNextPage)],
|
slivers: [
|
||||||
|
..._buildNotificationSlivers(context),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -162,32 +172,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSingleColumn(
|
Widget _buildSingleColumn(BuildContext context) {
|
||||||
BuildContext context,
|
|
||||||
PagingState<int, oott_model.Notification> state,
|
|
||||||
void Function() fetchNextPage,
|
|
||||||
) {
|
|
||||||
final isEmpty =
|
|
||||||
!state.isLoading && state.items != null && state.items!.isEmpty;
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: _buildNotificationsHeader(context, state)),
|
SliverToBoxAdapter(child: _buildNotificationsHeader(context)),
|
||||||
if (isEmpty)
|
..._buildNotificationSlivers(context),
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'No items found',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_buildNotificationSliver(state, fetchNextPage),
|
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(top: 24),
|
padding: EdgeInsets.only(top: 24),
|
||||||
@@ -204,10 +193,38 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNotificationsHeader(
|
List<Widget> _buildNotificationSlivers(BuildContext context) {
|
||||||
BuildContext context,
|
if (_isLoading) {
|
||||||
PagingState<int, oott_model.Notification> state,
|
return [
|
||||||
) {
|
const SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
return [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No items found',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
_buildNotificationSliver(),
|
||||||
|
if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(context),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationsHeader(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -218,8 +235,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (_filter == _NotificationFilter.newOnly &&
|
if (_filter == _NotificationFilter.newOnly && _items.isNotEmpty)
|
||||||
(state.items?.isNotEmpty ?? false))
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => _markAllAsRead(context),
|
onPressed: () => _markAllAsRead(context),
|
||||||
icon: const Icon(Icons.done_all),
|
icon: const Icon(Icons.done_all),
|
||||||
@@ -239,7 +255,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
selected: _filter == f,
|
selected: _filter == f,
|
||||||
onSelected: (_) {
|
onSelected: (_) {
|
||||||
setState(() => _filter = f);
|
setState(() => _filter = f);
|
||||||
_pagingController.refresh();
|
_fetchPage(0);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -250,19 +266,50 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNotificationSliver(
|
Widget _buildNotificationSliver() {
|
||||||
PagingState<int, oott_model.Notification> state,
|
|
||||||
void Function() fetchNextPage,
|
|
||||||
) {
|
|
||||||
final formatter = FriendlyDateFormatter();
|
final formatter = FriendlyDateFormatter();
|
||||||
return PagedSliverList<int, oott_model.Notification>(
|
return SliverList.builder(
|
||||||
state: state,
|
itemCount: _items.length,
|
||||||
fetchNextPage: fetchNextPage,
|
itemBuilder: (context, index) {
|
||||||
builderDelegate: PagedChildBuilderDelegate(
|
final item = _items[index];
|
||||||
itemBuilder: (context, item, index) => NotificationCard(
|
return NotificationCard(
|
||||||
item: item,
|
item: item,
|
||||||
formatter: formatter,
|
formatter: formatter,
|
||||||
onSetRead: (ctx, read) => _setRead(ctx, item, read),
|
onSetRead: (ctx, read) => _setRead(ctx, item, read),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPaginationControls(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton.outlined(
|
||||||
|
onPressed: _currentPage > 0 && !_isLoading
|
||||||
|
? () => _fetchPage(_currentPage - 1)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
tooltip: 'Previous page',
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Page ${_currentPage + 1}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton.outlined(
|
||||||
|
onPressed: _hasNextPage && !_isLoading
|
||||||
|
? () => _fetchPage(_currentPage + 1)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
tooltip: 'Next page',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -172,7 +172,11 @@ class BackendAPI {
|
|||||||
return ArpScannerStatus.fromJson(response.data as Map<String, dynamic>);
|
return ArpScannerStatus.fromJson(response.data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Notification>> listNotifications(bool? isNew, int offset) async {
|
Future<List<Notification>> listNotifications(
|
||||||
|
bool? isNew,
|
||||||
|
int offset, {
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
debugPrint('About to call /notifications');
|
debugPrint('About to call /notifications');
|
||||||
|
|
||||||
Response response;
|
Response response;
|
||||||
@@ -181,7 +185,7 @@ class BackendAPI {
|
|||||||
queryParameters: {
|
queryParameters: {
|
||||||
'is_new': isNew ?? '',
|
'is_new': isNew ?? '',
|
||||||
'page_offset': offset,
|
'page_offset': offset,
|
||||||
'page_limit': _pageSize,
|
'page_limit': limit ?? _pageSize,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -166,14 +166,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
flutter_staggered_grid_view:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_staggered_grid_view
|
|
||||||
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.0"
|
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -224,14 +216,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
infinite_scroll_pagination:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: infinite_scroll_pagination
|
|
||||||
sha256: b0d28e37cd8f62490ff6aef63f9db93d4c78b7f11b7c6b26f33c69d8476fda78
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.1.1"
|
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -525,14 +509,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
sliver_tools:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sliver_tools
|
|
||||||
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.12"
|
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ dependencies:
|
|||||||
dio: ^5.9.1
|
dio: ^5.9.1
|
||||||
encrypter: ^2.0.0
|
encrypter: ^2.0.0
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
infinite_scroll_pagination: ^5.1.1
|
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user