diff --git a/lib/friendica_client/friendica_client.dart b/lib/friendica_client/friendica_client.dart index ffa9d3d..b9a609d 100644 --- a/lib/friendica_client/friendica_client.dart +++ b/lib/friendica_client/friendica_client.dart @@ -50,21 +50,19 @@ class FriendicaClient { _networkStatusService = getIt(); } - // TODO Convert Notifications to using paging for real - FutureResult, ExecError> getNotifications() async { + FutureResult>, 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 clearNotifications() async { diff --git a/lib/friendica_client/paged_response.dart b/lib/friendica_client/paged_response.dart index af6aef4..6a13f68 100644 --- a/lib/friendica_client/paged_response.dart +++ b/lib/friendica_client/paged_response.dart @@ -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 { + 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 { func(data), previous: previous, next: next, + id: id, ); @override diff --git a/lib/friendica_client/pages_manager.dart b/lib/friendica_client/pages_manager.dart new file mode 100644 index 0000000..6a7b6a5 --- /dev/null +++ b/lib/friendica_client/pages_manager.dart @@ -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 { + final _pages = >>[]; + final List Function(TResult) idMapper; + final FutureResult, ExecError> Function(PagingData) + onRequest; + + PagesManager({ + required this.idMapper, + required this.onRequest, + }); + + UnmodifiableListView get pages => UnmodifiableListView(_pages); + + void clear() { + _pages.clear(); + } + + Result>, 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, 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, ExecError> nextWithPage( + PagedResponse> currentPage) async { + return _previousOrNext(currentPage.id, false); + } + + FutureResult, ExecError> previousWithPage( + PagedResponse> currentPage) async { + return _previousOrNext(currentPage.id, true); + } + + FutureResult, ExecError> nextWithResult( + PagedResponse currentPage) async { + return _previousOrNext(currentPage.id, false); + } + + FutureResult, ExecError> previousWithResult( + PagedResponse currentPage) async { + return _previousOrNext(currentPage.id, true); + } + + FutureResult, ExecError> nextFromEnd() async { + return _previousOrNext(_pages.last.id, false); + } + + FutureResult, ExecError> + previousFromBeginning() async { + return _previousOrNext(_pages.first.id, true); + } + + FutureResult, 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; + } +} diff --git a/lib/friendica_client/paging_data.dart b/lib/friendica_client/paging_data.dart index b326b5f..b768ac7 100644 --- a/lib/friendica_client/paging_data.dart +++ b/lib/friendica_client/paging_data.dart @@ -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 => diff --git a/lib/models/exec_error.dart b/lib/models/exec_error.dart index 7440ef2..dadf98d 100644 --- a/lib/models/exec_error.dart +++ b/lib/models/exec_error.dart @@ -39,6 +39,7 @@ enum ErrorType { notFound, parsingError, serverError, + rangeError, } extension ExecErrorExtension on Result { diff --git a/lib/screens/notifications_screen.dart b/lib/screens/notifications_screen.dart index eb8effa..5b05abf 100644 --- a/lib/screens/notifications_screen.dart +++ b/lib/screens/notifications_screen.dart @@ -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), ); } diff --git a/lib/services/notifications_manager.dart b/lib/services/notifications_manager.dart index df3df91..3855be3 100644 --- a/lib/services/notifications_manager.dart +++ b/lib/services/notifications_manager.dart @@ -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 = {}; + final _pm = PagesManager, String>( + idMapper: (nn) => nn.map((n) => n.id).toList(), + onRequest: _clientGetNotificationsRequest, + ); List get notifications { final result = List.from(_notifications.values); @@ -36,20 +43,42 @@ class NotificationsManager extends ChangeNotifier { } FutureResult, ExecError> updateNotifications() async { - final auth = getIt(); - final clientResult = auth.currentClient; - if (clientResult.isFailure) { - _logger.severe('Error getting Friendica client: ${clientResult.error}'); - return clientResult.errorCast(); + final nn = []; + 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, 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, 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 markSeen(UserNotification notification) async { final auth = getIt(); 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>, ExecError> + _clientGetNotificationsRequest(PagingData page) async { + final auth = getIt(); + 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; + } } diff --git a/test/pages_manager_test.dart b/test/pages_manager_test.dart new file mode 100644 index 0000000..4f26ab5 --- /dev/null +++ b/test/pages_manager_test.dart @@ -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 = []; + 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 = []; + 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 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, int> _buildPagesManager() => PagesManager( + idMapper: (data) => data.map((e) => e.id).toList(), + onRequest: getDataElements); + +FutureResult>, 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), + ), + ); +}