Merge branch 'paging-infra-upgrades' into 'main'

Paging infra upgrades

See merge request mysocialportal/relatica!20
This commit is contained in:
HankG 2023-02-14 13:49:21 +00:00
commit 2c5d411766
8 changed files with 401 additions and 35 deletions

View file

@ -50,21 +50,19 @@ class FriendicaClient {
_networkStatusService = getIt<NetworkStatusService>();
}
// TODO Convert Notifications to using paging for real
FutureResult<List<UserNotification>, ExecError> getNotifications() async {
FutureResult<PagedResponse<List<UserNotification>>, ExecError>
getNotifications(PagingData page) async {
_networkStatusService.startNotificationUpdate();
final url =
'https://$serverName/api/v1/notifications?include_all=true&limit=200';
final request = Uri.parse(url);
final url = 'https://$serverName/api/v1/notifications?include_all=true';
final request = Uri.parse('$url&${page.toQueryParameters()}');
_logger.finest(() => 'Getting new notifications');
final result = (await _getApiListRequest(request).andThenSuccessAsync(
(notificationsJson) async => notificationsJson.data
.map((json) => NotificationMastodonExtension.fromJson(json))
.toList()))
.execErrorCast();
final result = await _getApiListRequest(request);
_networkStatusService.finishNotificationUpdate();
return result;
return result
.andThenSuccess((response) => response.map((jsonArray) => jsonArray
.map((json) => NotificationMastodonExtension.fromJson(json))
.toList()))
.execErrorCast();
}
FutureResult<bool, ExecError> clearNotifications() async {

View file

@ -1,5 +1,6 @@
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import 'package:uuid/uuid.dart';
import '../models/exec_error.dart';
import 'paging_data.dart';
@ -7,11 +8,13 @@ import 'paging_data.dart';
final _logger = Logger('PagedResponse');
class PagedResponse<T> {
String id;
PagingData? previous;
PagingData? next;
T data;
PagedResponse(this.data, {this.previous, this.next});
PagedResponse(this.data, {String? id, this.previous, this.next})
: id = id ?? Uuid().v4();
bool get hasMorePages => previous != null || next != null;
@ -76,6 +79,7 @@ class PagedResponse<T> {
func(data),
previous: previous,
next: next,
id: id,
);
@override

View file

@ -0,0 +1,109 @@
import 'dart:collection';
import 'package:result_monad/result_monad.dart';
import '../models/exec_error.dart';
import 'paged_response.dart';
import 'paging_data.dart';
class PagesManager<TResult, TID> {
final _pages = <PagedResponse<List<TID>>>[];
final List<TID> Function(TResult) idMapper;
final FutureResult<PagedResponse<TResult>, ExecError> Function(PagingData)
onRequest;
PagesManager({
required this.idMapper,
required this.onRequest,
});
UnmodifiableListView<PagedResponse> get pages => UnmodifiableListView(_pages);
void clear() {
_pages.clear();
}
Result<PagedResponse<List<TID>>, ExecError> pageFromId(TID id) {
for (final p in _pages) {
if (p.data.contains(id)) {
return Result.ok(p);
}
}
return buildErrorResult(
type: ErrorType.notFound, message: 'ID $id not in any page');
}
FutureResult<PagedResponse<TResult>, ExecError> initialize(int limit) async {
if (_pages.isNotEmpty) {
return buildErrorResult(
type: ErrorType.rangeError,
message: 'Cannot initialize a loaded manager');
}
final result = await onRequest(PagingData(limit: limit));
if (result.isSuccess) {
final newPage = result.value.map((data) => idMapper(data));
_pages.add(newPage);
}
return result;
}
FutureResult<PagedResponse<TResult>, ExecError> nextWithPage(
PagedResponse<List<TID>> currentPage) async {
return _previousOrNext(currentPage.id, false);
}
FutureResult<PagedResponse<TResult>, ExecError> previousWithPage(
PagedResponse<List<TID>> currentPage) async {
return _previousOrNext(currentPage.id, true);
}
FutureResult<PagedResponse<TResult>, ExecError> nextWithResult(
PagedResponse<TResult> currentPage) async {
return _previousOrNext(currentPage.id, false);
}
FutureResult<PagedResponse<TResult>, ExecError> previousWithResult(
PagedResponse<TResult> currentPage) async {
return _previousOrNext(currentPage.id, true);
}
FutureResult<PagedResponse<TResult>, ExecError> nextFromEnd() async {
return _previousOrNext(_pages.last.id, false);
}
FutureResult<PagedResponse<TResult>, ExecError>
previousFromBeginning() async {
return _previousOrNext(_pages.first.id, true);
}
FutureResult<PagedResponse<TResult>, ExecError> _previousOrNext(
String id, bool asPrevious) async {
final currentIndex = _pages.indexWhere((p) => p.id == id);
if (currentIndex < 0) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'Passed in page is not part of this manager',
);
}
final currentPage = _pages[currentIndex];
final newPagingData = asPrevious ? currentPage.previous : currentPage.next;
if (newPagingData == null) {
return buildErrorResult(
type: ErrorType.rangeError,
message: asPrevious ? 'No previous page' : 'No next page',
);
}
final result = await onRequest(newPagingData);
if (result.isSuccess && result.value.hasMorePages) {
final newPage = result.value.map((data) => idMapper(data));
if (asPrevious) {
_pages.insert(currentIndex, newPage);
} else {
_pages.insert(currentIndex + 1, newPage);
}
}
return result;
}
}

View file

@ -52,6 +52,9 @@ class PagingData {
return pagingData;
}
bool get isLimitOnly =>
minId == null && maxId == null && sinceId == null && offset == null;
@override
String toString() {
return 'PagingData{maxId: $maxId, minId: $minId, sinceId: $sinceId, offset: $offset, limit: $limit}';
@ -60,13 +63,13 @@ class PagingData {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PagingData &&
runtimeType == other.runtimeType &&
minId == other.minId &&
maxId == other.maxId &&
sinceId == other.sinceId &&
offset == other.offset &&
limit == other.limit;
other is PagingData &&
runtimeType == other.runtimeType &&
minId == other.minId &&
maxId == other.maxId &&
sinceId == other.sinceId &&
offset == other.offset &&
limit == other.limit;
@override
int get hashCode =>

View file

@ -39,6 +39,7 @@ enum ErrorType {
notFound,
parsingError,
serverError,
rangeError,
}
extension ExecErrorExtension<T, E> on Result<T, E> {

View file

@ -42,7 +42,36 @@ class NotificationsScreen extends StatelessWidget {
},
child: ListView.separated(
itemBuilder: (context, index) {
return NotificationControl(notification: notifications[index]);
if (index == 0) {
return TextButton(
onPressed: () async {
final result = await manager.loadNewerNotifications();
final noMore = result.fold(
onSuccess: (values) => values.isEmpty,
onError: (_) => true);
if (context.mounted && noMore) {
buildSnackbar(
context, 'No newer notifications to load');
}
},
child: const Text('Load newer notifications'));
}
if (index == notifications.length + 1) {
return TextButton(
onPressed: () async {
final result = await manager.loadOlderNotifications();
final noMore = result.fold(
onSuccess: (values) => values.isEmpty,
onError: (_) => true);
if (context.mounted && noMore) {
buildSnackbar(
context, 'No older notifications to load');
}
},
child: const Text('Load older notifications'));
}
return NotificationControl(
notification: notifications[index - 1]);
},
separatorBuilder: (context, index) {
return const Divider(
@ -50,7 +79,7 @@ class NotificationsScreen extends StatelessWidget {
height: 0.0,
);
},
itemCount: notifications.length + 1),
itemCount: notifications.length + 2),
);
}

View file

@ -4,6 +4,9 @@ import 'package:relatica/services/network_status_service.dart';
import 'package:result_monad/result_monad.dart';
import 'package:uuid/uuid.dart';
import '../friendica_client/paged_response.dart';
import '../friendica_client/pages_manager.dart';
import '../friendica_client/paging_data.dart';
import '../globals.dart';
import '../models/exec_error.dart';
import '../models/user_notification.dart';
@ -13,6 +16,10 @@ import 'direct_message_service.dart';
class NotificationsManager extends ChangeNotifier {
static final _logger = Logger('NotificationManager');
final _notifications = <String, UserNotification>{};
final _pm = PagesManager<List<UserNotification>, String>(
idMapper: (nn) => nn.map((n) => n.id).toList(),
onRequest: _clientGetNotificationsRequest,
);
List<UserNotification> get notifications {
final result = List<UserNotification>.from(_notifications.values);
@ -36,20 +43,42 @@ class NotificationsManager extends ChangeNotifier {
}
FutureResult<List<UserNotification>, ExecError> updateNotifications() async {
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
final nn = <UserNotification>[];
if (_pm.pages.isEmpty) {
final result = await _pm.initialize(25);
result.andThenSuccess((response) => nn.addAll(response.data));
} else {
for (var i = 0; i < _pm.pages.length - 1; i++) {
final page = _pm.pages[i];
PagingData? pd;
if (i == 0) {
if (page.previous == null) {
_logger.severe(
"Expected first page to have a previous page so can query on this page of data but doesn't exist.");
continue;
}
final response = await _clientGetNotificationsRequest(page.next!);
response.match(
onSuccess: (response) => pd = response.previous,
onError: (error) =>
_logger.severe('Error getting previous page: $error'));
} else {
pd = page.next;
}
if (pd == null) {
_logger.severe('Paging data for next page was unexpectedly null');
continue;
}
final response = await _clientGetNotificationsRequest(pd!);
response.match(
onSuccess: (response) => nn.addAll(response.data),
onError: (error) =>
_logger.severe('Error getting previous page: $error'));
}
}
final client = clientResult.value;
final result = await client.getNotifications();
if (result.isFailure) {
return result.errorCast();
}
for (final n in result.value) {
for (final n in nn) {
_notifications[n.id] = n;
}
@ -67,6 +96,36 @@ class NotificationsManager extends ChangeNotifier {
return Result.ok(notifications);
}
FutureResult<List<UserNotification>, ExecError>
loadNewerNotifications() async {
final result = await _pm.previousFromBeginning();
result.match(onSuccess: (response) {
for (final n in response.data) {
_notifications[n.id] = n;
}
notifyListeners();
}, onError: (error) {
_logger.info('Error getting more updates: $error');
});
return result.mapValue((response) => response.data);
}
FutureResult<List<UserNotification>, ExecError>
loadOlderNotifications() async {
final result = await _pm.nextFromEnd();
result.match(onSuccess: (response) {
for (final n in response.data) {
_notifications[n.id] = n;
}
notifyListeners();
}, onError: (error) {
_logger.info('Error getting more updates: $error');
});
return result.mapValue((response) => response.data);
}
FutureResult<bool, ExecError> markSeen(UserNotification notification) async {
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
@ -98,7 +157,9 @@ class NotificationsManager extends ChangeNotifier {
if (result.isFailure) {
return result.errorCast();
}
notifyListeners();
_pm.clear();
_notifications.clear();
return updateNotifications();
}
@ -124,4 +185,18 @@ class NotificationsManager extends ChangeNotifier {
return result;
}
static FutureResult<PagedResponse<List<UserNotification>>, ExecError>
_clientGetNotificationsRequest(PagingData page) async {
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
_logger.severe('Error getting Friendica client: ${clientResult.error}');
return clientResult.errorCast();
}
final client = clientResult.value;
final result = await client.getNotifications(page);
return result;
}
}

View file

@ -0,0 +1,147 @@
import 'dart:collection';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:relatica/friendica_client/paged_response.dart';
import 'package:relatica/friendica_client/pages_manager.dart';
import 'package:relatica/friendica_client/paging_data.dart';
import 'package:relatica/models/exec_error.dart';
import 'package:result_monad/result_monad.dart';
//Ensure works for ascending and descending tests
void main() async {
test('Full range test', () async {
final pm = _buildPagesManager();
final numbers = <int>[];
final initial = await pm.initialize(10);
initial.value.data.forEach((e) => numbers.add(e.id));
var current = initial.value;
while (current.next != null) {
final result = await pm.nextWithResult(current);
current = result.value;
result.value.data.forEach((e) => numbers.add(e.id));
}
current = initial.value;
while (current.previous != null) {
final result = await pm.previousWithResult(current);
current = result.value;
result.value.data.forEach((e) => numbers.add(e.id));
}
numbers.sort();
final expected = elements.map((e) => e.id).toList();
expected.sort();
expect(numbers.length, equals(elements.length));
expect(numbers, equals(expected));
_checkPagesOrder(pm.pages);
});
test('End fills test', () async {
final pm = _buildPagesManager();
final numbers = <int>[];
final initial = await pm.initialize(10);
initial.value.data.reversed.forEach((e) => numbers.add(e.id));
var moreWork = true;
while (moreWork) {
final nextFromEnd = await pm.nextFromEnd();
final previousFromBeginning = await pm.previousFromBeginning();
nextFromEnd.andThenSuccess(
(r) => r.data.reversed.forEach((e) => numbers.add(e.id)));
previousFromBeginning.andThenSuccess(
(r) => r.data.forEach((e) => numbers.insert(0, e.id)));
moreWork = nextFromEnd.isSuccess || previousFromBeginning.isSuccess;
}
for (var i = 0; i < numbers.length - 1; i++) {
expect(numbers[i], greaterThan(numbers[i + 1]));
}
numbers.sort();
expect(numbers.length, equals(elements.length));
expect(numbers, equals(elements.map((e) => e.id)));
_checkPagesOrder(pm.pages);
});
test('Can find page by index', () async {
final pm = _buildPagesManager();
final initial = await pm.initialize(10);
final next = await pm.nextWithResult(initial.value);
final previous = await pm.previousWithResult(initial.value);
final initialFromQuery = pm.pageFromId(initial.value.data.first.id);
expect(initialFromQuery.value.id, equals(initial.value.id));
final nextFromQuery = pm.pageFromId(next.value.data.first.id);
expect(nextFromQuery.value.id, equals(next.value.id));
final previousFromQuery = pm.pageFromId(previous.value.data.first.id);
expect(previousFromQuery.value.id, equals(previous.value.id));
expect(pm.pageFromId(elements.last.id).isFailure, true);
});
}
void _checkPagesOrder(UnmodifiableListView<PagedResponse> pages) {
expect(pages.first.previous, equals(null));
expect(pages.last.next, equals(null));
for (var i = 1; i < pages.length - 2; i++) {
final p0 = pages[i];
final p1 = pages[i + 1];
expect(p0.previous!.minId, greaterThan(p1.previous!.minId!));
expect(p0.next!.maxId, greaterThan(p1.next!.maxId!));
}
}
class _DataElement {
final int id;
final int value;
_DataElement({required this.id, required this.value});
@override
String toString() {
return '_DataElement{id: $id}';
}
}
const count = 1000;
final elements = List.generate(
count, (index) => _DataElement(id: index, value: Random().nextInt(100)));
PagesManager<List<_DataElement>, int> _buildPagesManager() => PagesManager(
idMapper: (data) => data.map((e) => e.id).toList(),
onRequest: getDataElements);
FutureResult<PagedResponse<List<_DataElement>>, ExecError> getDataElements(
PagingData page) async {
final count = page.limit;
late final int start;
late final int stop;
if (page.isLimitOnly) {
stop = elements.length ~/ 2;
start = stop - count;
} else if (page.minId != null) {
start = page.minId!;
stop = start + count;
} else if (page.maxId != null) {
stop = page.maxId!;
start = stop - count;
} else {
return buildErrorResult(
type: ErrorType.serverError,
message: 'Unknown paging type combo (only min and max supported)',
);
}
int previous = stop;
int next = start;
final data = elements.sublist(max(0, start), min(elements.length, stop));
return Result.ok(
PagedResponse(
data,
previous: previous > elements.length - 1
? null
: PagingData(limit: count, minId: previous),
next: next < 0 ? null : PagingData(limit: count, maxId: next),
),
);
}