mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 19:23:31 +00:00
Merge branch 'paging-infra-upgrades' into 'main'
Paging infra upgrades See merge request mysocialportal/relatica!20
This commit is contained in:
commit
2c5d411766
8 changed files with 401 additions and 35 deletions
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
109
lib/friendica_client/pages_manager.dart
Normal file
109
lib/friendica_client/pages_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 =>
|
||||
|
|
|
@ -39,6 +39,7 @@ enum ErrorType {
|
|||
notFound,
|
||||
parsingError,
|
||||
serverError,
|
||||
rangeError,
|
||||
}
|
||||
|
||||
extension ExecErrorExtension<T, E> on Result<T, E> {
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
147
test/pages_manager_test.dart
Normal file
147
test/pages_manager_test.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue