diff --git a/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart b/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart index e903e3326e1..01172260543 100644 --- a/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart +++ b/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'dart:math'; import 'package:built_collection/built_collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:meta/meta.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; +import 'package:neon_framework/utils.dart'; import 'package:nextcloud/dashboard.dart' as dashboard; import 'package:rxdart/rxdart.dart'; @@ -16,7 +16,10 @@ sealed class DashboardBloc implements InteractiveBloc { factory DashboardBloc(Account account) => _DashboardBloc(account); /// Dashboard widgets that are displayed. - BehaviorSubject>> get widgets; + BehaviorSubject>> get widgets; + + /// Dashboard items that are displayed. + BehaviorSubject>> get items; } /// Implementation of [DashboardBloc]. @@ -24,6 +27,52 @@ sealed class DashboardBloc implements InteractiveBloc { /// Automatically starts fetching the widgets and their items and refreshes everything every 30 seconds. class _DashboardBloc extends InteractiveBloc implements DashboardBloc { _DashboardBloc(this.account) { + itemsV1.listen((_) => _updateItems()); + itemsV2.listen((_) => _updateItems()); + + widgets.listen((result) async { + if (!result.hasData) { + return; + } + + final v1WidgetIDs = ListBuilder(); + final v2WidgetIDs = ListBuilder(); + + for (final widget in result.requireData) { + final itemApiVersions = widget.itemApiVersions; + if (itemApiVersions != null && itemApiVersions.contains(2)) { + v2WidgetIDs.add(widget.id); + } else { + v1WidgetIDs.add(widget.id); + } + } + + await Future.wait([ + if (v1WidgetIDs.isNotEmpty) + RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'dashboard-widgets-v1', + subject: itemsV1, + rawResponse: account.client.dashboard.dashboardApi.getWidgetItemsRaw( + widgets: v1WidgetIDs.build(), + limit: maxItems, + ), + unwrap: (response) => response.body.ocs.data, + ), + if (v2WidgetIDs.isNotEmpty) + RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'dashboard-widgets-v2', + subject: itemsV2, + rawResponse: account.client.dashboard.dashboardApi.getWidgetItemsV2Raw( + widgets: v2WidgetIDs.build(), + limit: maxItems, + ), + unwrap: (response) => response.body.ocs.data, + ), + ]); + }); + unawaited(refresh()); timer = TimerBloc().registerTimer(const Duration(seconds: 30), refresh); @@ -31,97 +80,74 @@ class _DashboardBloc extends InteractiveBloc implements DashboardBloc { final Account account; late final NeonTimer timer; + final itemsV1 = BehaviorSubject>>>(); + final itemsV2 = BehaviorSubject>>(); static const int maxItems = 7; @override - BehaviorSubject>> widgets = BehaviorSubject(); + final widgets = BehaviorSubject>>(); + + @override + final items = BehaviorSubject>>(); @override void dispose() { timer.cancel(); + unawaited(itemsV1.close()); + unawaited(itemsV2.close()); unawaited(widgets.close()); + unawaited(items.close()); super.dispose(); } @override Future refresh() async { - widgets.add(widgets.valueOrNull?.asLoading() ?? Result.loading()); - - try { - final widgets = {}; - final v1WidgetIDs = ListBuilder(); - final v2WidgetIDs = ListBuilder(); - - final response = await account.client.dashboard.dashboardApi.getWidgets(); - - for (final widget in response.body.ocs.data.values) { - final itemApiVersions = widget.itemApiVersions; - if (itemApiVersions != null && itemApiVersions.contains(2)) { - v2WidgetIDs.add(widget.id); - } else if (itemApiVersions == null || itemApiVersions.contains(1)) { - // If the field isn't present the server only supports v1 - v1WidgetIDs.add(widget.id); - } else { - debugPrint('Widget supports none of the API versions: ${widget.id}'); - } - } - - if (v1WidgetIDs.isNotEmpty) { - final widgetsIDs = v1WidgetIDs.build(); - debugPrint('Loading v1 widgets: ${widgetsIDs.join(', ')}'); + await RequestManager.instance.wrapNextcloud( + account: account, + cacheKey: 'dashboard-widgets', + subject: widgets, + rawResponse: account.client.dashboard.dashboardApi.getWidgetsRaw(), + // Filter all widgets that don't support v1 nor v2 + unwrap: (response) => BuiltList( + response.body.ocs.data.values + .where((widget) => widget.itemApiVersions == null || widget.itemApiVersions!.isNotEmpty), + ), + ); + } - final response = await account.client.dashboard.dashboardApi.getWidgetItems( - widgets: widgetsIDs, - limit: maxItems, + void _updateItems() { + final data = MapBuilder(); + + final resultV1 = itemsV1.valueOrNull; + if (resultV1 != null && resultV1.hasData) { + for (final entry in resultV1.requireData.entries) { + data[entry.key] = dashboard.WidgetItems( + (b) => b + ..items.replace(entry.value.sublist(0, min(entry.value.length, maxItems))) + ..emptyContentMessage = '' + ..halfEmptyContentMessage = '', ); - for (final entry in response.body.ocs.data.entries) { - widgets[entry.key] = dashboard.WidgetItems( - (b) => b - ..items = entry.value.sublist(0, min(entry.value.length, maxItems)).toBuilder() - ..emptyContentMessage = '' - ..halfEmptyContentMessage = '', - ); - } } + } - if (v2WidgetIDs.isNotEmpty) { - final widgetsIDs = v2WidgetIDs.build(); - debugPrint('Loading v2 widgets: ${widgetsIDs.join(', ')}'); - - final response = await account.client.dashboard.dashboardApi.getWidgetItemsV2( - widgets: widgetsIDs, - limit: maxItems, - ); - widgets.addEntries( - response.body.ocs.data.entries.map( - (entry) => MapEntry( - entry.key, - entry.value.rebuild((b) { - if (b.items.length > maxItems) { - b.items.removeRange(maxItems, b.items.length); - } - }), - ), - ), - ); + final resultV2 = itemsV2.valueOrNull; + if (resultV2 != null && resultV2.hasData) { + for (final entry in resultV2.requireData.entries) { + data[entry.key] = entry.value.rebuild((b) { + if (b.items.length > maxItems) { + b.items.removeRange(maxItems, b.items.length); + } + }); } - - this.widgets.add( - Result.success( - widgets.map( - (id, items) => MapEntry( - response.body.ocs.data.values.firstWhere((widget) => widget.id == id), - items, - ), - ), - ), - ); - } catch (e, s) { - debugPrint(e.toString()); - debugPrint(s.toString()); - - widgets.add(Result.error(e)); - return; } + + items.add( + Result( + data.build(), + resultV1?.error ?? resultV2?.error, + isLoading: (resultV1?.isLoading ?? true) || (resultV2?.isLoading ?? true), + isCached: (resultV1?.isCached ?? true) || (resultV2?.isCached ?? true), + ), + ); } } diff --git a/packages/neon/neon_dashboard/lib/src/pages/main.dart b/packages/neon/neon_dashboard/lib/src/pages/main.dart index c8fdcc26450..7f738d58b19 100644 --- a/packages/neon/neon_dashboard/lib/src/pages/main.dart +++ b/packages/neon/neon_dashboard/lib/src/pages/main.dart @@ -34,83 +34,86 @@ class DashboardMainPage extends StatelessWidget { return NeonCustomBackground( child: ResultBuilder.behaviorSubject( subject: bloc.widgets, - builder: (context, result) { - final children = [ - _buildStatuses( - account: accountsBloc.activeAccount.value!, - userStatusBloc: userStatusBloc, - weatherStatusBloc: weatherStatusBloc, - ), - ]; + builder: (context, widgetsResult) => ResultBuilder.behaviorSubject( + subject: bloc.items, + builder: (context, itemsResult) { + final children = [ + _buildStatuses( + account: accountsBloc.activeAccount.value!, + userStatusBloc: userStatusBloc, + weatherStatusBloc: weatherStatusBloc, + ), + ]; - var minHeight = 504.0; - if (result.hasData) { - final widgets = []; - for (final widget in result.requireData.entries) { - final items = buildWidgetItems( - context: context, - widget: widget.key, - items: widget.value, - ).toList(); + var minHeight = 504.0; + if (widgetsResult.hasData) { + final widgets = []; + for (final widget in widgetsResult.requireData) { + final items = buildWidgetItems( + context: context, + widget: widget, + items: itemsResult.data?[widget.id], + ).toList(); - final height = items.map((i) => i.height!).reduce((a, b) => a + b); - minHeight = max(minHeight, height); + final height = items.map((i) => i.height!).reduce((a, b) => a + b); + minHeight = max(minHeight, height); - widgets.add( - DashboardWidget( - widget: widget.key, - children: items, - ), - ); - } - - children.add( - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: widgets - .map( - (widget) => SizedBox( - width: 320, - height: minHeight + 24, - child: widget, - ), - ) - .toList(), - ), - ); - } else { - children.add( - SizedBox( - height: minHeight, - ), - ); - } + widgets.add( + DashboardWidget( + widget: widget, + children: items, + ), + ); + } - return Center( - child: NeonListView.custom( - scrollKey: 'dashboard', - isLoading: result.isLoading, - error: result.error, - onRefresh: bloc.refresh, - sliver: SliverFillRemaining( - hasScrollBody: false, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: children - .intersperse( - const SizedBox( - height: 40, + children.add( + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: widgets + .map( + (widget) => SizedBox( + width: 320, + height: minHeight + 24, + child: widget, ), ) .toList(), ), + ); + } else { + children.add( + SizedBox( + height: minHeight, + ), + ); + } + + return Center( + child: NeonListView.custom( + scrollKey: 'dashboard', + isLoading: widgetsResult.isLoading || itemsResult.isLoading, + error: widgetsResult.error ?? itemsResult.error, + onRefresh: bloc.refresh, + sliver: SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: children + .intersperse( + const SizedBox( + height: 40, + ), + ) + .toList(), + ), + ), ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/packages/neon/neon_dashboard/pubspec.yaml b/packages/neon/neon_dashboard/pubspec.yaml index 0c3e56652f0..be4bbcbb8c3 100644 --- a/packages/neon/neon_dashboard/pubspec.yaml +++ b/packages/neon/neon_dashboard/pubspec.yaml @@ -28,6 +28,7 @@ dev_dependencies: flutter_test: sdk: flutter go_router_builder: ^2.4.1 + http: ^1.2.0 mocktail: ^1.0.3 neon_lints: git: diff --git a/packages/neon/neon_dashboard/test/bloc_test.dart b/packages/neon/neon_dashboard/test/bloc_test.dart new file mode 100644 index 00000000000..d731f317b14 --- /dev/null +++ b/packages/neon/neon_dashboard/test/bloc_test.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_dashboard/src/blocs/dashboard.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/testing.dart'; + +Account mockDashboardAccount() => mockServer({ + RegExp(r'/ocs/v2\.php/apps/dashboard/api/v1/widgets'): { + 'get': (match, queryParameters) => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + for (final entry in { + 'null': null, + 'empty': [], + 'v1': [1], + 'v2': [2], + 'v1v2': [1, 2], + }.entries) + entry.key: { + 'id': entry.key, + 'title': '', + 'order': 0, + 'icon_class': '', + 'icon_url': '', + 'item_icons_round': false, + 'item_api_versions': entry.value, + }, + }, + }, + }), + 200, + ), + }, + RegExp(r'/ocs/v2\.php/apps/dashboard/api/v1/widget-items'): { + 'get': (match, queryParameters) => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + for (final key in queryParameters['widgets[]']!) + key: [ + { + 'subtitle': '', + 'title': key, + 'link': '', + 'iconUrl': '', + 'sinceId': '', + }, + ], + 'tooMany1': [ + for (var i = 0; i < 8; i++) + { + 'subtitle': '', + 'title': '$i', + 'link': '', + 'iconUrl': '', + 'sinceId': '', + }, + ], + }, + }, + }), + 200, + ), + }, + RegExp(r'/ocs/v2\.php/apps/dashboard/api/v2/widget-items'): { + 'get': (match, queryParameters) => Response( + json.encode({ + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + for (final key in queryParameters['widgets[]']!) + key: { + 'items': [ + { + 'subtitle': '', + 'title': key, + 'link': '', + 'iconUrl': '', + 'sinceId': '', + }, + ], + 'emptyContentMessage': '', + 'halfEmptyContentMessage': '', + }, + 'tooMany2': { + 'items': [ + for (var i = 0; i < 8; i++) + { + 'subtitle': '', + 'title': '$i', + 'link': '', + 'iconUrl': '', + 'sinceId': '', + }, + ], + 'emptyContentMessage': '', + 'halfEmptyContentMessage': '', + }, + }, + }, + }), + 200, + ), + }, + }); + +void main() { + late Account account; + late DashboardBloc bloc; + + setUpAll(() { + final storage = MockNeonStorage(); + when(() => storage.requestCache).thenReturn(null); + }); + + setUp(() { + account = mockDashboardAccount(); + bloc = DashboardBloc(account); + }); + + test('refresh', () async { + final widgets = BuiltList([ + 'null', + 'v1', + 'v2', + 'v1v2', + ]); + expect( + bloc.widgets.transformResult((e) => BuiltList(e.map((w) => w.id))), + emitsInOrder([ + Result>.loading(), + Result.success(widgets), + Result.success(widgets).asLoading(), + Result.success(widgets), + ]), + ); + + final items = BuiltList([ + 'null', + 'v1', + for (var i = 0; i < 7; i++) '$i', + 'v2', + 'v1v2', + for (var i = 0; i < 7; i++) '$i', + ]); + expect( + bloc.items + .transformResult( + (e) => BuiltList(e.values.map((items) => items.items.map((item) => item.title)).flattened), + ) + .distinct(), + emitsInOrder([ + Result(BuiltList(), null, isLoading: true, isCached: false), + Result.success(items), + Result.success(items).asLoading(), + Result.success(items), + ]), + ); + // The delay is necessary to avoid a race condition with loading twice at the same time + await Future.delayed(const Duration(milliseconds: 1)); + await bloc.refresh(); + }); +} diff --git a/packages/neon_framework/lib/src/testing/mock_server.dart b/packages/neon_framework/lib/src/testing/mock_server.dart index eac9c498faf..190ba7dcb27 100644 --- a/packages/neon_framework/lib/src/testing/mock_server.dart +++ b/packages/neon_framework/lib/src/testing/mock_server.dart @@ -6,7 +6,7 @@ import 'package:neon_framework/src/models/account.dart'; /// /// To be used for end-to-end testing `Bloc`s. Account mockServer( - Map queryParameters)>> requests, + Map> queryParameters)>> requests, ) => Account( serverURL: Uri.parse('https://example.com'), @@ -18,7 +18,7 @@ Account mockServer( if (match != null) { final call = entry.value[request.method]; if (call != null) { - return call(match, request.url.queryParameters); + return call(match, request.url.queryParametersAll); } } } diff --git a/packages/neon_framework/test/user_status_bloc_test.dart b/packages/neon_framework/test/user_status_bloc_test.dart index 8d39e4de530..d55ff25f6d3 100644 --- a/packages/neon_framework/test/user_status_bloc_test.dart +++ b/packages/neon_framework/test/user_status_bloc_test.dart @@ -74,25 +74,25 @@ Account mockUserStatusAccount() { }, RegExp(r'/ocs/v2\.php/apps/user_status/api/v1/user_status/message/predefined'): { 'put': (match, queryParameters) { - messageId = queryParameters['messageId']; + messageId = queryParameters['messageId']!.single; messageIsPredefined = true; - clearAt = int.parse(queryParameters['clearAt']!); + clearAt = int.parse(queryParameters['clearAt']!.single); return statusResponse(); }, }, RegExp(r'/ocs/v2\.php/apps/user_status/api/v1/user_status/message/custom'): { 'put': (match, queryParameters) { messageId = null; - message = queryParameters['message']; + message = queryParameters['message']!.single; messageIsPredefined = false; - icon = queryParameters['statusIcon']; - clearAt = int.parse(queryParameters['clearAt']!); + icon = queryParameters['statusIcon']!.single; + clearAt = int.parse(queryParameters['clearAt']!.single); return statusResponse(); }, }, RegExp(r'/ocs/v2\.php/apps/user_status/api/v1/user_status/status'): { 'put': (match, queryParameters) { - status = queryParameters['statusType']!; + status = queryParameters['statusType']!.single; statusIsUserDefined = true; return statusResponse(); }, @@ -119,7 +119,7 @@ Account mockUserStatusAccount() { return Response('', 201); } - status = queryParameters['status']!; + status = queryParameters['status']!.single; statusIsUserDefined = false; return statusResponse(); }, diff --git a/packages/neon_framework/test/weather_status_bloc_test.dart b/packages/neon_framework/test/weather_status_bloc_test.dart index 4d54fbb44b6..e10c54e8a88 100644 --- a/packages/neon_framework/test/weather_status_bloc_test.dart +++ b/packages/neon_framework/test/weather_status_bloc_test.dart @@ -34,9 +34,9 @@ Account mockWeatherStatusAccount() { RegExp(r'/ocs/v2\.php/apps/weather_status/api/v1/location'): { 'get': (match, queryParameters) => locationResponse(), 'put': (match, queryParameters) { - lat = queryParameters['lat']; - lon = queryParameters['lon']; - address = queryParameters['address']; + lat = queryParameters['lat']?.single; + lon = queryParameters['lon']?.single; + address = queryParameters['address']!.single; return locationResponse(); }, },