Browse Source

Merge pull request #940 from nextcloud/feature/dashboard

Add dashboard app
pull/1003/head
Kate 1 year ago committed by GitHub
parent
commit
279e17f095
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .cspell/dart_flutter.txt
  2. 2
      README.md
  3. 1
      commitlint.yaml
  4. BIN
      packages/app/android/app/src/main/res/mipmap-hdpi/app_dashboard.png
  5. BIN
      packages/app/android/app/src/main/res/mipmap-mdpi/app_dashboard.png
  6. BIN
      packages/app/android/app/src/main/res/mipmap-xhdpi/app_dashboard.png
  7. BIN
      packages/app/android/app/src/main/res/mipmap-xxhdpi/app_dashboard.png
  8. BIN
      packages/app/android/app/src/main/res/mipmap-xxxhdpi/app_dashboard.png
  9. 2
      packages/app/lib/apps.dart
  10. 7
      packages/app/pubspec.lock
  11. 4
      packages/app/pubspec.yaml
  12. 4
      packages/app/pubspec_overrides.yaml
  13. 2
      packages/neon/neon/lib/l10n/en.arb
  14. 2
      packages/neon/neon/lib/l10n/localizations.dart
  15. 1
      packages/neon/neon/lib/l10n/localizations_en.dart
  16. 11
      packages/neon/neon/lib/src/blocs/apps.dart
  17. 10
      packages/neon/neon_dashboard/.metadata
  18. 1
      packages/neon/neon_dashboard/LICENSE
  19. 5
      packages/neon/neon_dashboard/analysis_options.yaml
  20. BIN
      packages/neon/neon_dashboard/assets/app.svg.vec
  21. 0
      packages/neon/neon_dashboard/build.yaml
  22. 6
      packages/neon/neon_dashboard/l10n.yaml
  23. 4
      packages/neon/neon_dashboard/lib/l10n/en.arb
  24. 125
      packages/neon/neon_dashboard/lib/l10n/localizations.dart
  25. 9
      packages/neon/neon_dashboard/lib/l10n/localizations_en.dart
  26. 1
      packages/neon/neon_dashboard/lib/neon_dashboard.dart
  27. 44
      packages/neon/neon_dashboard/lib/src/app.dart
  28. 103
      packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart
  29. 10
      packages/neon/neon_dashboard/lib/src/options.dart
  30. 55
      packages/neon/neon_dashboard/lib/src/pages/main.dart
  31. 21
      packages/neon/neon_dashboard/lib/src/routes.dart
  32. 33
      packages/neon/neon_dashboard/lib/src/routes.g.dart
  33. 15
      packages/neon/neon_dashboard/lib/src/utils/find.dart
  34. 101
      packages/neon/neon_dashboard/lib/src/widgets/widget.dart
  35. 43
      packages/neon/neon_dashboard/lib/src/widgets/widget_button.dart
  36. 67
      packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart
  37. 43
      packages/neon/neon_dashboard/pubspec.yaml
  38. 12
      packages/neon/neon_dashboard/pubspec_overrides.yaml
  39. 30
      packages/neon/neon_dashboard/test/find_test.dart
  40. BIN
      packages/neon/neon_dashboard/test/goldens/widget.png
  41. BIN
      packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png
  42. BIN
      packages/neon/neon_dashboard/test/goldens/widget_button_more.png
  43. BIN
      packages/neon/neon_dashboard/test/goldens/widget_button_new.png
  44. BIN
      packages/neon/neon_dashboard/test/goldens/widget_button_setup.png
  45. BIN
      packages/neon/neon_dashboard/test/goldens/widget_item.png
  46. BIN
      packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png
  47. BIN
      packages/neon/neon_dashboard/test/goldens/widget_not_round.png
  48. BIN
      packages/neon/neon_dashboard/test/goldens/widget_with_empty.png
  49. BIN
      packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png
  50. BIN
      packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png
  51. BIN
      packages/neon/neon_dashboard/test/goldens/widget_without_items.png
  52. 407
      packages/neon/neon_dashboard/test/widget_test.dart
  53. 1
      tool/generate-assets.sh

1
.cspell/dart_flutter.txt

@ -2,6 +2,7 @@ autofocus
endtemplate
expando
gapless
goldens
lerp
pubspec
sublist

2
README.md

@ -49,6 +49,7 @@ See [here](packages/app/README.md) for screenshots.
| App | Status |
|---------------------------------------------------|--------------------|
| [Dashboard](packages/neon/neon_dashboard) | :heavy_check_mark: |
| [Files](packages/neon/neon_files) | :heavy_check_mark: |
| [News](packages/neon/neon_news) | :heavy_check_mark: |
| [Notes](packages/neon/neon_notes) | :heavy_check_mark: |
@ -57,7 +58,6 @@ See [here](packages/app/README.md) for screenshots.
| Calendar | :rocket: |
| Contacts | :rocket: |
| Cookbook | :rocket: |
| Dashboard | :rocket: |
| Photos | :rocket: |
| Talk | :rocket: |
| Tasks | :rocket: |

1
commitlint.yaml

@ -16,6 +16,7 @@ rules:
- dynamite_runtime
- file_icons
- neon
- neon_dashboard
- neon_files
- neon_news
- neon_notes

BIN
packages/app/android/app/src/main/res/mipmap-hdpi/app_dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
packages/app/android/app/src/main/res/mipmap-mdpi/app_dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
packages/app/android/app/src/main/res/mipmap-xhdpi/app_dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
packages/app/android/app/src/main/res/mipmap-xxhdpi/app_dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
packages/app/android/app/src/main/res/mipmap-xxxhdpi/app_dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

2
packages/app/lib/apps.dart

@ -1,4 +1,5 @@
import 'package:neon/models.dart';
import 'package:neon_dashboard/neon_dashboard.dart';
import 'package:neon_files/neon_files.dart';
import 'package:neon_news/neon_news.dart';
import 'package:neon_notes/neon_notes.dart';
@ -6,6 +7,7 @@ import 'package:neon_notifications/neon_notifications.dart';
/// The collection of clients enabled for the Neon app.
final Set<AppImplementation> appImplementations = {
DashboardApp(),
FilesApp(),
NewsApp(),
NotesApp(),

7
packages/app/pubspec.lock

@ -649,6 +649,13 @@ packages:
relative: true
source: path
version: "1.0.0"
neon_dashboard:
dependency: "direct main"
description:
path: "../neon/neon_dashboard"
relative: true
source: path
version: "1.0.0"
neon_files:
dependency: "direct main"
description:

4
packages/app/pubspec.yaml

@ -13,6 +13,10 @@ dependencies:
git:
url: https://github.com/nextcloud/neon
path: packages/neon/neon
neon_dashboard:
git:
url: https://github.com/nextcloud/neon
path: packages/neon/neon_dashboard
neon_files:
git:
url: https://github.com/nextcloud/neon

4
packages/app/pubspec_overrides.yaml

@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints
# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints,neon_dashboard
dependency_overrides:
dynamite_runtime:
path: ../dynamite/dynamite_runtime
@ -6,6 +6,8 @@ dependency_overrides:
path: ../file_icons
neon:
path: ../neon/neon
neon_dashboard:
path: ../neon/neon_dashboard
neon_files:
path: ../neon/neon_files
neon_lints:

2
packages/neon/neon/lib/l10n/en.arb

@ -2,7 +2,7 @@
"@@locale": "en",
"nextcloud": "Nextcloud",
"nextcloudLogo": "Nextcloud logo",
"appImplementationName": "{app, select, nextcloud{Nextcloud} core{Server} files{Files} news{News} notes{Notes} notifications{Notifications} other{}}",
"appImplementationName": "{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} other{}}",
"@appImplementationName": {
"placeholders": {
"app": {}

2
packages/neon/neon/lib/l10n/localizations.dart

@ -104,7 +104,7 @@ abstract class NeonLocalizations {
/// No description provided for @appImplementationName.
///
/// In en, this message translates to:
/// **'{app, select, nextcloud{Nextcloud} core{Server} files{Files} news{News} notes{Notes} notifications{Notifications} other{}}'**
/// **'{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} other{}}'**
String appImplementationName(String app);
/// No description provided for @loginAgain.

1
packages/neon/neon/lib/l10n/localizations_en.dart

@ -19,6 +19,7 @@ class NeonLocalizationsEn extends NeonLocalizations {
{
'nextcloud': 'Nextcloud',
'core': 'Server',
'dashboard': 'Dashboard',
'files': 'Files',
'news': 'News',
'notes': 'Notes',

11
packages/neon/neon/lib/src/blocs/apps.dart

@ -97,9 +97,14 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates
/// Returns null when no app is supported by the server.
String? _getInitialAppFallback() {
final supportedApps = appImplementations.value.requireData;
if (supportedApps.tryFind(AppIDs.files) != null) {
return AppIDs.files;
} else if (supportedApps.isNotEmpty) {
for (final fallback in {AppIDs.dashboard, AppIDs.files}) {
if (supportedApps.tryFind(fallback) != null) {
return fallback;
}
}
if (supportedApps.isNotEmpty) {
return supportedApps.first.id;
}

10
packages/neon/neon_dashboard/.metadata

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ead455963c12b453cdb2358cad34969c76daf180"
channel: "stable"
project_type: package

1
packages/neon/neon_dashboard/LICENSE

@ -0,0 +1 @@
../../../LICENSE

5
packages/neon/neon_dashboard/analysis_options.yaml

@ -0,0 +1,5 @@
include: package:neon_lints/flutter.yaml
analyzer:
exclude:
- lib/l10n/**

BIN
packages/neon/neon_dashboard/assets/app.svg.vec

Binary file not shown.

0
packages/neon/neon_dashboard/build.yaml

6
packages/neon/neon_dashboard/l10n.yaml

@ -0,0 +1,6 @@
arb-dir: lib/l10n
template-arb-file: en.arb
output-localization-file: localizations.dart
synthetic-package: false
output-dir: lib/l10n
nullable-getter: false

4
packages/neon/neon_dashboard/lib/l10n/en.arb

@ -0,0 +1,4 @@
{
"@@locale": "en",
"noEntries": "No entries"
}

125
packages/neon/neon_dashboard/lib/l10n/localizations.dart

@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'localizations_en.dart';
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
/// No description provided for @noEntries.
///
/// In en, this message translates to:
/// **'No entries'**
String get noEntries;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) => <String>['en'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
}
throw FlutterError('AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.');
}

9
packages/neon/neon_dashboard/lib/l10n/localizations_en.dart

@ -0,0 +1,9 @@
import 'localizations.dart';
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get noEntries => 'No entries';
}

1
packages/neon/neon_dashboard/lib/neon_dashboard.dart

@ -0,0 +1 @@
export 'src/app.dart';

44
packages/neon/neon_dashboard/lib/src/app.dart

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:neon/models.dart';
import 'package:neon_dashboard/l10n/localizations.dart';
import 'package:neon_dashboard/src/blocs/dashboard.dart';
import 'package:neon_dashboard/src/options.dart';
import 'package:neon_dashboard/src/pages/main.dart';
import 'package:neon_dashboard/src/routes.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/nextcloud.dart';
/// Implementation of the server `dashboard` app.
class DashboardApp extends AppImplementation<DashboardBloc, DashboardAppSpecificOptions> {
/// Creates a new Dashboard app implementation instance.
DashboardApp();
@override
final String id = AppIDs.dashboard;
@override
final LocalizationsDelegate<AppLocalizations> localizationsDelegate = AppLocalizations.delegate;
@override
final List<Locale> supportedLocales = AppLocalizations.supportedLocales;
@override
late final DashboardAppSpecificOptions options = DashboardAppSpecificOptions(storage);
@override
DashboardBloc buildBloc(final Account account) => DashboardBloc(account);
@override
final Widget page = const DashboardMainPage();
@override
final RouteBase route = $dashboardAppRoute;
@override
(bool?, String?) isSupported(
final Account account,
final core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data capabilities,
) =>
const (null, null);
}

103
packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart

@ -0,0 +1,103 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:neon/blocs.dart';
import 'package:neon/models.dart';
import 'package:neon_dashboard/src/utils/find.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
import 'package:rxdart/rxdart.dart';
/// Events for [DashboardBloc].
abstract class DashboardBlocEvents {}
/// States for [DashboardBloc].
abstract class DashboardBlocStates {
/// Dashboard widgets that are displayed.
BehaviorSubject<Result<Map<dashboard.Widget, dashboard.WidgetItems?>>> get widgets;
}
/// Implements the business logic for fetching dashboard widgets and their items.
class DashboardBloc extends InteractiveBloc implements DashboardBlocEvents, DashboardBlocStates {
/// Creates a new Dashboard Bloc instance.
///
/// Automatically starts fetching the widgets and their items and refreshes everything every 30 seconds.
DashboardBloc(this._account) {
unawaited(refresh());
_timer = TimerBloc().registerTimer(const Duration(seconds: 30), refresh);
}
final Account _account;
late final NeonTimer _timer;
@override
BehaviorSubject<Result<Map<dashboard.Widget, dashboard.WidgetItems?>>> widgets = BehaviorSubject();
@override
void dispose() {
_timer.cancel();
unawaited(widgets.close());
super.dispose();
}
@override
Future<void> refresh() async {
widgets.add(widgets.valueOrNull?.asLoading() ?? Result.loading());
try {
final widgets = <String, dashboard.WidgetItems?>{};
final v1WidgetIDs = <String>[];
final v2WidgetIDs = <String>[];
final response = await _account.client.dashboard.dashboardApi.getWidgets();
for (final widget in response.body.ocs.data.values) {
if (widget.itemApiVersions.contains(2)) {
v2WidgetIDs.add(widget.id);
} else if (widget.itemApiVersions.contains(1)) {
v1WidgetIDs.add(widget.id);
} else {
debugPrint('Widget supports none of the API versions: ${widget.id}');
}
}
if (v1WidgetIDs.isNotEmpty) {
debugPrint('Loading v1 widgets: ${v1WidgetIDs.join(', ')}');
final response = await _account.client.dashboard.dashboardApi.getWidgetItems(widgets: v1WidgetIDs);
for (final entry in response.body.ocs.data.entries) {
widgets[entry.key] = dashboard.WidgetItems(
(final b) => b
..items.replace(entry.value)
..emptyContentMessage = ''
..halfEmptyContentMessage = '',
);
}
}
if (v2WidgetIDs.isNotEmpty) {
debugPrint('Loading v2 widgets: ${v2WidgetIDs.join(', ')}');
final response = await _account.client.dashboard.dashboardApi.getWidgetItemsV2(widgets: v2WidgetIDs);
widgets.addEntries(response.body.ocs.data.entries);
}
this.widgets.add(
Result.success(
widgets.map(
(final id, final items) => MapEntry(
response.body.ocs.data.values.find(id),
items,
),
),
),
);
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
widgets.add(Result.error(e));
return;
}
}
}

10
packages/neon/neon_dashboard/lib/src/options.dart

@ -0,0 +1,10 @@
import 'package:neon/settings.dart';
/// Settings options specific to the dashboard app.
class DashboardAppSpecificOptions extends NextcloudAppOptions {
/// Creates a new dashboard options instance.
DashboardAppSpecificOptions(super.storage) {
super.categories = [];
super.options = [];
}
}

55
packages/neon/neon_dashboard/lib/src/pages/main.dart

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:neon/blocs.dart';
import 'package:neon/utils.dart';
import 'package:neon/widgets.dart';
import 'package:neon_dashboard/src/blocs/dashboard.dart';
import 'package:neon_dashboard/src/widgets/widget.dart';
/// Displays the whole dashboard page layout.
class DashboardMainPage extends StatelessWidget {
/// Creates a new dashboard main page.
const DashboardMainPage({
super.key,
});
@override
Widget build(final BuildContext context) {
final bloc = NeonProvider.of<DashboardBloc>(context);
return ResultBuilder.behaviorSubject(
stream: bloc.widgets,
builder: (final context, final snapshot) {
Widget? child;
if (snapshot.hasData) {
child = Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: snapshot.requireData.entries
.map(
(final widget) => DashboardWidget(
widget: widget.key,
items: widget.value,
),
)
.toList(),
);
}
return Center(
child: NeonListView.custom(
isLoading: snapshot.isLoading,
error: snapshot.error,
onRefresh: bloc.refresh,
sliver: SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: child,
),
),
),
);
},
);
}
}

21
packages/neon/neon_dashboard/lib/src/routes.dart

@ -0,0 +1,21 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:neon/utils.dart';
import 'package:neon_dashboard/src/pages/main.dart';
import 'package:nextcloud/nextcloud.dart';
part 'routes.g.dart';
/// Route for the dashboard app.
@TypedGoRoute<DashboardAppRoute>(
path: '$appsBaseRoutePrefix${AppIDs.dashboard}',
name: AppIDs.dashboard,
)
@immutable
class DashboardAppRoute extends NeonBaseAppRoute {
/// Creates a new dashboard app route.
const DashboardAppRoute();
@override
Widget build(final BuildContext context, final GoRouterState state) => const DashboardMainPage();
}

33
packages/neon/neon_dashboard/lib/src/routes.g.dart

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'routes.dart';
// **************************************************************************
// GoRouterGenerator
// **************************************************************************
List<RouteBase> get $appRoutes => [
$dashboardAppRoute,
];
RouteBase get $dashboardAppRoute => GoRouteData.$route(
path: '/apps/dashboard',
name: 'dashboard',
factory: $DashboardAppRouteExtension._fromState,
);
extension $DashboardAppRouteExtension on DashboardAppRoute {
static DashboardAppRoute _fromState(GoRouterState state) => const DashboardAppRoute();
String get location => GoRouteData.$location(
'/apps/dashboard',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) => context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}

15
packages/neon/neon_dashboard/lib/src/utils/find.dart

@ -0,0 +1,15 @@
import 'package:collection/collection.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
/// Extension to find [dashboard.Widget]s.
extension WidgetFind on Iterable<dashboard.Widget> {
/// Finds the first widget that has the id set to [id].
///
/// Returns `null` if no matching widget was found.
dashboard.Widget? tryFind(final String id) => firstWhereOrNull((final widget) => widget.id == id);
/// Finds the first widget that has the id set to [id].
///
/// Throws an exception if no matching widget was found.
dashboard.Widget find(final String id) => firstWhere((final widget) => widget.id == id);
}

101
packages/neon/neon_dashboard/lib/src/widgets/widget.dart

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:neon/theme.dart';
import 'package:neon/widgets.dart';
import 'package:neon_dashboard/l10n/localizations.dart';
import 'package:neon_dashboard/src/widgets/widget_button.dart';
import 'package:neon_dashboard/src/widgets/widget_item.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
/// Displays a single dashboard widget and its items.
class DashboardWidget extends StatelessWidget {
/// Creates a new dashboard widget items.
const DashboardWidget({
required this.widget,
required this.items,
super.key,
});
/// The dashboard widget to be displayed.
final dashboard.Widget widget;
/// The items of the widget to be displayed.
final dashboard.WidgetItems? items;
@override
Widget build(final BuildContext context) {
final halfEmptyContentMessage = _renderMessage(items?.halfEmptyContentMessage);
final emptyContentMessage = _renderMessage(items?.emptyContentMessage);
return SizedBox(
width: 320,
height: 560,
child: Card(
child: InkWell(
onTap: widget.widgetUrl != null && widget.widgetUrl!.isNotEmpty ? () => context.go(widget.widgetUrl!) : null,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView(
padding: const EdgeInsets.all(8),
children: [
ListTile(
title: Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
leading: SizedBox.square(
dimension: largeIconSize,
child: NeonUrlImage(
url: widget.iconUrl,
svgColorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn),
size: const Size.square(largeIconSize),
),
),
),
const SizedBox(
height: 20,
),
if (halfEmptyContentMessage != null) halfEmptyContentMessage,
if (emptyContentMessage != null) emptyContentMessage,
if (halfEmptyContentMessage == null && emptyContentMessage == null && (items?.items.isEmpty ?? true))
_renderMessage(AppLocalizations.of(context).noEntries)!,
...?items?.items.map(
(final item) => DashboardWidgetItem(
item: item,
roundIcon: widget.itemIconsRound,
),
),
const SizedBox(
height: 20,
),
...?widget.buttons?.map(
(final button) => DashboardWidgetButton(
button: button,
),
),
],
),
),
),
);
}
Widget? _renderMessage(final String? message) {
if (message == null || message.isEmpty) {
return null;
}
return Center(
child: Column(
children: [
const Icon(
Icons.check,
size: largeIconSize,
),
Text(message),
],
),
);
}
}

43
packages/neon/neon_dashboard/lib/src/widgets/widget_button.dart

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
/// Button inside a dashboard widget that is used to trigger an action.
class DashboardWidgetButton extends StatelessWidget {
/// Creates a new dashboard widget button.
const DashboardWidgetButton({
required this.button,
super.key,
});
/// The dashboard widget button to be displayed.
final dashboard.Widget_Buttons button;
@override
Widget build(final BuildContext context) {
void onPressed() => context.go(button.link);
final label = Text(button.text);
final icon = switch (button.type) {
'new' => Icons.add,
'more' => Icons.more_outlined,
'setup' => Icons.launch,
_ => null,
};
if (icon != null) {
return Align(
child: FilledButton.icon(
onPressed: onPressed,
icon: Icon(icon),
label: label,
),
);
}
return Align(
child: FilledButton(
onPressed: onPressed,
child: label,
),
);
}
}

67
packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:neon/theme.dart';
import 'package:neon/widgets.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
/// A single item in the dashboard widget.
class DashboardWidgetItem extends StatelessWidget {
/// Creates a new dashboard widget item.
const DashboardWidgetItem({
required this.item,
required this.roundIcon,
super.key,
});
/// The dashboard widget item to be displayed.
final dashboard.WidgetItem item;
/// Whether the leading icon should have round corners.
final bool roundIcon;
@override
Widget build(final BuildContext context) {
Widget leading = SizedBox.square(
dimension: largeIconSize,
child: NeonImageWrapper(
borderRadius: roundIcon ? BorderRadius.circular(largeIconSize) : null,
child: NeonUrlImage(
url: item.iconUrl,
size: const Size.square(largeIconSize),
),
),
);
if (item.overlayIconUrl.isNotEmpty) {
leading = Stack(
children: [
leading,
SizedBox.square(
dimension: largeIconSize,
child: Align(
alignment: Alignment.bottomRight,
child: SizedBox.square(
dimension: smallIconSize,
child: NeonUrlImage(
url: item.overlayIconUrl,
size: const Size.square(smallIconSize),
),
),
),
),
],
);
}
return ListTile(
title: Text(
item.title,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
item.subtitle,
overflow: TextOverflow.ellipsis,
),
leading: leading,
onTap: item.link.isNotEmpty ? () => context.go(item.link) : null,
);
}
}

43
packages/neon/neon_dashboard/pubspec.yaml

@ -0,0 +1,43 @@
name: neon_dashboard
version: 1.0.0
publish_to: 'none'
environment:
sdk: '>=3.1.0 <4.0.0'
flutter: '>=3.13.0'
dependencies:
collection: ^1.0.0
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
go_router: ^12.0.0
neon:
git:
url: https://github.com/nextcloud/neon
path: packages/neon/neon
nextcloud:
git:
url: https://github.com/nextcloud/neon
path: packages/nextcloud
rxdart: ^0.27.0
dev_dependencies:
build_runner: ^2.4.6
built_collection: ^5.1.1
flutter_test:
sdk: flutter
go_router_builder: ^2.3.3
mocktail: ^1.0.1
neon_lints:
git:
url: https://github.com/nextcloud/neon
path: packages/neon_lints
test: ^1.24.3
vector_graphics_compiler: ^1.1.9
flutter:
uses-material-design: true
assets:
- assets/

12
packages/neon/neon_dashboard/pubspec_overrides.yaml

@ -0,0 +1,12 @@
# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box
dependency_overrides:
dynamite_runtime:
path: ../../dynamite/dynamite_runtime
neon:
path: ../neon
neon_lints:
path: ../../neon_lints
nextcloud:
path: ../../nextcloud
sort_box:
path: ../../sort_box

30
packages/neon/neon_dashboard/test/find_test.dart

@ -0,0 +1,30 @@
import 'package:mocktail/mocktail.dart';
import 'package:neon_dashboard/src/utils/find.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
import 'package:test/test.dart';
// ignore: missing_override_of_must_be_overridden, avoid_implementing_value_types
class DashboardWidgetMock extends Mock implements dashboard.Widget {}
void main() {
group('group name', () {
test('AccountFind', () {
final widget1 = DashboardWidgetMock();
final widget2 = DashboardWidgetMock();
final widgets = {
widget1,
widget2,
};
when(() => widget1.id).thenReturn('widget1');
when(() => widget2.id).thenReturn('widget2');
expect(widgets.tryFind('invalidID'), isNull);
expect(widgets.tryFind(widget2.id), equals(widget2));
expect(() => widgets.find('invalidID'), throwsA(isA<StateError>()));
expect(widgets.find(widget2.id), equals(widget2));
});
});
}

BIN
packages/neon/neon_dashboard/test/goldens/widget.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_button_more.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_button_new.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_button_setup.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_item.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_not_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_with_empty.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
packages/neon/neon_dashboard/test/goldens/widget_without_items.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

407
packages/neon/neon_dashboard/test/widget_test.dart

@ -0,0 +1,407 @@
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:neon/blocs.dart';
import 'package:neon/models.dart';
import 'package:neon/theme.dart';
import 'package:neon/utils.dart';
import 'package:neon/widgets.dart';
import 'package:neon_dashboard/l10n/localizations.dart';
import 'package:neon_dashboard/src/widgets/widget.dart';
import 'package:neon_dashboard/src/widgets/widget_button.dart';
import 'package:neon_dashboard/src/widgets/widget_item.dart';
import 'package:nextcloud/dashboard.dart' as dashboard;
import 'package:rxdart/rxdart.dart';
class MockAccountsBloc extends Mock implements AccountsBloc {}
Widget wrapWidget(final AccountsBloc accountsBloc, final Widget child) => MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
backgroundColor: Colors.transparent,
body: NeonProvider<AccountsBloc>.value(
value: accountsBloc,
child: child,
),
),
);
void main() {
final accountsBloc = MockAccountsBloc();
when(() => accountsBloc.activeAccount).thenAnswer(
(final invocation) => BehaviorSubject.seeded(
Account(
serverURL: Uri(),
username: 'example',
),
),
);
group('Widget item', () {
final item = dashboard.WidgetItem(
(final b) => b
..title = 'Widget item title'
..subtitle = 'Widget item subtitle'
..link = 'https://example.com/link'
..iconUrl = 'https://example.com/iconUrl'
..overlayIconUrl = 'https://example.com/overlayIconUrl'
..sinceId = '',
);
testWidgets('Everything filled', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetItem(
item: item,
roundIcon: true,
),
),
);
expect(find.text('Widget item title'), findsOneWidget);
expect(find.text('Widget item subtitle'), findsOneWidget);
expect(find.byType(InkWell), findsOneWidget);
expect(
tester.widget(find.byType(InkWell)),
isA<InkWell>().having(
(final a) => a.onTap,
'onTap is not null',
isNotNull,
),
);
expect(find.byType(NeonImageWrapper), findsOneWidget);
expect(
tester.widget(find.byType(NeonImageWrapper)),
isA<NeonImageWrapper>().having(
(final a) => a.borderRadius,
'borderRadius is correct',
BorderRadius.circular(largeIconSize),
),
);
expect(find.byType(NeonCachedImage), findsNWidgets(2));
expect(find.byType(DashboardWidgetItem), matchesGoldenFile('goldens/widget_item.png'));
});
testWidgets('Not round', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetItem(
item: item,
roundIcon: false,
),
),
);
expect(
tester.widget(find.byType(NeonImageWrapper)),
isA<NeonImageWrapper>().having(
(final a) => a.borderRadius,
'borderRadius is null',
null,
),
);
expect(find.byType(DashboardWidgetItem), matchesGoldenFile('goldens/widget_item_not_round.png'));
});
testWidgets('Without link', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetItem(
item: item.rebuild((final b) => b..link = ''),
roundIcon: true,
),
),
);
expect(
tester.widget(find.byType(InkWell)),
isA<InkWell>().having(
(final a) => a.onTap,
'onTap is null',
isNull,
),
);
});
testWidgets('Without overlayIconUrl', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetItem(
item: item.rebuild((final b) => b..overlayIconUrl = ''),
roundIcon: true,
),
),
);
expect(find.byType(NeonCachedImage), findsOneWidget);
});
});
group('Widget button', () {
final button = dashboard.Widget_Buttons(
(final b) => b
..type = 'new'
..text = 'Button'
..link = 'https://example.com/link',
);
testWidgets('New', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetButton(
button: button,
),
),
);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.text('Button'), findsOneWidget);
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_new.png'));
});
testWidgets('More', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetButton(
button: button.rebuild((final b) => b.type = 'more'),
),
),
);
expect(find.byIcon(Icons.more_outlined), findsOneWidget);
expect(find.text('Button'), findsOneWidget);
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_more.png'));
});
testWidgets('Setup', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetButton(
button: button.rebuild((final b) => b.type = 'setup'),
),
),
);
expect(find.byIcon(Icons.launch), findsOneWidget);
expect(find.text('Button'), findsOneWidget);
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_setup.png'));
});
testWidgets('Invalid', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidgetButton(
button: button.rebuild((final b) => b.type = 'test'),
),
),
);
expect(find.byType(Icon), findsNothing);
expect(find.text('Button'), findsOneWidget);
expect(find.byType(DashboardWidgetButton), matchesGoldenFile('goldens/widget_button_invalid.png'));
});
});
group('Widget', () {
final item = dashboard.WidgetItem(
(final b) => b
..title = 'Widget item title'
..subtitle = 'Widget item subtitle'
..link = 'https://example.com/link'
..iconUrl = 'https://example.com/iconUrl'
..overlayIconUrl = 'https://example.com/overlayIconUrl'
..sinceId = '',
);
final items = dashboard.WidgetItems(
(final b) => b
..items = BuiltList<dashboard.WidgetItem>.from([item]).toBuilder()
..emptyContentMessage = ''
..halfEmptyContentMessage = '',
);
final button = dashboard.Widget_Buttons(
(final b) => b
..type = 'new'
..text = 'Button'
..link = 'https://example.com/link',
);
final widget = dashboard.Widget(
(final b) => b
..id = 'id'
..title = 'Widget title'
..order = 0
..iconClass = ''
..iconUrl = 'https://example.com/iconUrl'
..widgetUrl = 'https://example.com/widgetUrl'
..itemIconsRound = true
..itemApiVersions = BuiltList<int>.from([1, 2]).toBuilder()
..reloadInterval = 0
..buttons = BuiltList<dashboard.Widget_Buttons>.from([button]).toBuilder(),
);
testWidgets('Everything filled', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget,
items: items,
),
),
);
expect(find.text('Widget title'), findsOneWidget);
expect(find.byType(InkWell), findsNWidgets(4));
expect(
tester.widget(find.byType(InkWell).first),
isA<InkWell>().having(
(final a) => a.onTap,
'onTap is not null',
isNotNull,
),
);
expect(find.byType(NeonImageWrapper), findsOneWidget);
expect(
tester.widget(find.byType(NeonImageWrapper)),
isA<NeonImageWrapper>().having(
(final a) => a.borderRadius,
'borderRadius is correct',
BorderRadius.circular(largeIconSize),
),
);
expect(find.byType(NeonCachedImage), findsNWidgets(3));
expect(find.byType(DashboardWidgetItem), findsOneWidget);
expect(find.bySubtype<FilledButton>(), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.text('Button'), findsOneWidget);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget.png'));
});
testWidgets('Without widgetUrl', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget.rebuild((final b) => b.widgetUrl = ''),
items: items,
),
),
);
expect(
tester.widget(find.byType(InkWell).first),
isA<InkWell>().having(
(final a) => a.onTap,
'onTap is null',
isNull,
),
);
});
testWidgets('Not round', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget.rebuild((final b) => b.itemIconsRound = false),
items: items,
),
),
);
expect(
tester.widget(find.byType(NeonImageWrapper)),
isA<NeonImageWrapper>().having(
(final a) => a.borderRadius,
'borderRadius is null',
null,
),
);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_not_round.png'));
});
testWidgets('With halfEmptyContentMessage', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget,
items: items.rebuild((final b) => b.halfEmptyContentMessage = 'Half empty'),
),
),
);
expect(find.text('Half empty'), findsOneWidget);
expect(find.byIcon(Icons.check), findsOneWidget);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_with_half_empty.png'));
});
testWidgets('With emptyContentMessage', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget,
items: items.rebuild((final b) => b.emptyContentMessage = 'Empty'),
),
),
);
expect(find.text('Empty'), findsOneWidget);
expect(find.byIcon(Icons.check), findsOneWidget);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_with_empty.png'));
});
testWidgets('Without items', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget,
items: null,
),
),
);
expect(find.text('No entries'), findsOneWidget);
expect(find.byIcon(Icons.check), findsOneWidget);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_without_items.png'));
});
testWidgets('Without buttons', (final tester) async {
await tester.pumpWidget(
wrapWidget(
accountsBloc,
DashboardWidget(
widget: widget.rebuild((final b) => b.buttons.clear()),
items: items,
),
),
);
expect(find.bySubtype<FilledButton>(), findsNothing);
expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_without_buttons.png'));
});
});
}

1
tool/generate-assets.sh

@ -68,6 +68,7 @@ done
precompile_assets
)
copy_app_svg dashboard external/nextcloud-server/apps/dashboard
copy_app_svg files external/nextcloud-server/apps/files
copy_app_svg news external/nextcloud-news
copy_app_svg notes external/nextcloud-notes

Loading…
Cancel
Save