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..7f746ab --- /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) { + 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/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), + ), + ); +}