From 96dfed0e22c65de0be3d104d11447c509db0f8c6 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Sun, 1 Oct 2023 22:01:26 +0200 Subject: [PATCH] feat(neon_dashboard): Init Signed-off-by: jld3103 --- .cspell/dart_flutter.txt | 1 + commitlint.yaml | 1 + packages/neon/neon_dashboard/.metadata | 10 + packages/neon/neon_dashboard/LICENSE | 1 + .../neon/neon_dashboard/analysis_options.yaml | 5 + .../neon/neon_dashboard/assets/app.svg.vec | Bin 0 -> 292 bytes packages/neon/neon_dashboard/build.yaml | 0 packages/neon/neon_dashboard/l10n.yaml | 6 + packages/neon/neon_dashboard/lib/l10n/en.arb | 4 + .../lib/l10n/localizations.dart | 125 ++++++ .../lib/l10n/localizations_en.dart | 9 + .../neon_dashboard/lib/neon_dashboard.dart | 1 + packages/neon/neon_dashboard/lib/src/app.dart | 44 ++ .../lib/src/blocs/dashboard.dart | 103 +++++ .../neon/neon_dashboard/lib/src/options.dart | 10 + .../neon_dashboard/lib/src/pages/main.dart | 55 +++ .../neon/neon_dashboard/lib/src/routes.dart | 21 + .../neon/neon_dashboard/lib/src/routes.g.dart | 33 ++ .../neon_dashboard/lib/src/utils/find.dart | 15 + .../lib/src/widgets/widget.dart | 101 +++++ .../lib/src/widgets/widget_button.dart | 43 ++ .../lib/src/widgets/widget_item.dart | 67 +++ packages/neon/neon_dashboard/pubspec.yaml | 43 ++ .../neon_dashboard/pubspec_overrides.yaml | 12 + .../neon/neon_dashboard/test/find_test.dart | 30 ++ .../neon_dashboard/test/goldens/widget.png | Bin 0 -> 4792 bytes .../test/goldens/widget_button_invalid.png | Bin 0 -> 2959 bytes .../test/goldens/widget_button_more.png | Bin 0 -> 3006 bytes .../test/goldens/widget_button_new.png | Bin 0 -> 3006 bytes .../test/goldens/widget_button_setup.png | Bin 0 -> 3006 bytes .../test/goldens/widget_item.png | Bin 0 -> 2771 bytes .../test/goldens/widget_item_not_round.png | Bin 0 -> 2437 bytes .../test/goldens/widget_not_round.png | Bin 0 -> 4792 bytes .../test/goldens/widget_with_empty.png | Bin 0 -> 4885 bytes .../test/goldens/widget_with_half_empty.png | Bin 0 -> 4890 bytes .../test/goldens/widget_without_buttons.png | Bin 0 -> 3746 bytes .../test/goldens/widget_without_items.png | Bin 0 -> 4782 bytes .../neon/neon_dashboard/test/widget_test.dart | 407 ++++++++++++++++++ 38 files changed, 1147 insertions(+) create mode 100644 packages/neon/neon_dashboard/.metadata create mode 120000 packages/neon/neon_dashboard/LICENSE create mode 100644 packages/neon/neon_dashboard/analysis_options.yaml create mode 100644 packages/neon/neon_dashboard/assets/app.svg.vec create mode 100644 packages/neon/neon_dashboard/build.yaml create mode 100644 packages/neon/neon_dashboard/l10n.yaml create mode 100644 packages/neon/neon_dashboard/lib/l10n/en.arb create mode 100644 packages/neon/neon_dashboard/lib/l10n/localizations.dart create mode 100644 packages/neon/neon_dashboard/lib/l10n/localizations_en.dart create mode 100644 packages/neon/neon_dashboard/lib/neon_dashboard.dart create mode 100644 packages/neon/neon_dashboard/lib/src/app.dart create mode 100644 packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart create mode 100644 packages/neon/neon_dashboard/lib/src/options.dart create mode 100644 packages/neon/neon_dashboard/lib/src/pages/main.dart create mode 100644 packages/neon/neon_dashboard/lib/src/routes.dart create mode 100644 packages/neon/neon_dashboard/lib/src/routes.g.dart create mode 100644 packages/neon/neon_dashboard/lib/src/utils/find.dart create mode 100644 packages/neon/neon_dashboard/lib/src/widgets/widget.dart create mode 100644 packages/neon/neon_dashboard/lib/src/widgets/widget_button.dart create mode 100644 packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart create mode 100644 packages/neon/neon_dashboard/pubspec.yaml create mode 100644 packages/neon/neon_dashboard/pubspec_overrides.yaml create mode 100644 packages/neon/neon_dashboard/test/find_test.dart create mode 100644 packages/neon/neon_dashboard/test/goldens/widget.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_button_more.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_button_new.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_button_setup.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_item.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_not_round.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_with_empty.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png create mode 100644 packages/neon/neon_dashboard/test/goldens/widget_without_items.png create mode 100644 packages/neon/neon_dashboard/test/widget_test.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index 82f3cf3e..f1f2d865 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -2,6 +2,7 @@ autofocus endtemplate expando gapless +goldens lerp pubspec sublist diff --git a/commitlint.yaml b/commitlint.yaml index 8aea72a8..bce3939e 100644 --- a/commitlint.yaml +++ b/commitlint.yaml @@ -16,6 +16,7 @@ rules: - dynamite_runtime - file_icons - neon + - neon_dashboard - neon_files - neon_news - neon_notes diff --git a/packages/neon/neon_dashboard/.metadata b/packages/neon/neon_dashboard/.metadata new file mode 100644 index 00000000..9d75c7ec --- /dev/null +++ b/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 diff --git a/packages/neon/neon_dashboard/LICENSE b/packages/neon/neon_dashboard/LICENSE new file mode 120000 index 00000000..5853aaea --- /dev/null +++ b/packages/neon/neon_dashboard/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/packages/neon/neon_dashboard/analysis_options.yaml b/packages/neon/neon_dashboard/analysis_options.yaml new file mode 100644 index 00000000..66de1efd --- /dev/null +++ b/packages/neon/neon_dashboard/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:neon_lints/flutter.yaml + +analyzer: + exclude: + - lib/l10n/** diff --git a/packages/neon/neon_dashboard/assets/app.svg.vec b/packages/neon/neon_dashboard/assets/app.svg.vec new file mode 100644 index 0000000000000000000000000000000000000000..13983ab79a807b00b7d80f9373903258fc67acd6 GIT binary patch literal 292 zcmYe&?ORfcNPPPL{|+1X=Qu23RC6rZk>jx8qKjkjpUn;r&bt83 zW^e@YZvf>$@=t*589?=-7hD`){od>V;xFEw;{cL>xGx9jJ_ax!q#q;?G7qGF6VQAE KIiO2`VF3VfI%V7d literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/build.yaml b/packages/neon/neon_dashboard/build.yaml new file mode 100644 index 00000000..e69de29b diff --git a/packages/neon/neon_dashboard/l10n.yaml b/packages/neon/neon_dashboard/l10n.yaml new file mode 100644 index 00000000..7974745e --- /dev/null +++ b/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 diff --git a/packages/neon/neon_dashboard/lib/l10n/en.arb b/packages/neon/neon_dashboard/lib/l10n/en.arb new file mode 100644 index 00000000..2e2110b8 --- /dev/null +++ b/packages/neon/neon_dashboard/lib/l10n/en.arb @@ -0,0 +1,4 @@ +{ + "@@locale": "en", + "noEntries": "No entries" +} diff --git a/packages/neon/neon_dashboard/lib/l10n/localizations.dart b/packages/neon/neon_dashboard/lib/l10n/localizations.dart new file mode 100644 index 00000000..0a2e2f7e --- /dev/null +++ b/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, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s 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(context, AppLocalizations)!; + } + + static const LocalizationsDelegate 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> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('en')]; + + /// No description provided for @noEntries. + /// + /// In en, this message translates to: + /// **'No entries'** + String get noEntries; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['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.'); +} diff --git a/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart b/packages/neon/neon_dashboard/lib/l10n/localizations_en.dart new file mode 100644 index 00000000..d4a69229 --- /dev/null +++ b/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'; +} diff --git a/packages/neon/neon_dashboard/lib/neon_dashboard.dart b/packages/neon/neon_dashboard/lib/neon_dashboard.dart new file mode 100644 index 00000000..76f2a5f3 --- /dev/null +++ b/packages/neon/neon_dashboard/lib/neon_dashboard.dart @@ -0,0 +1 @@ +export 'src/app.dart'; diff --git a/packages/neon/neon_dashboard/lib/src/app.dart b/packages/neon/neon_dashboard/lib/src/app.dart new file mode 100644 index 00000000..b1958f67 --- /dev/null +++ b/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 { + /// Creates a new Dashboard app implementation instance. + DashboardApp(); + + @override + final String id = AppIDs.dashboard; + + @override + final LocalizationsDelegate localizationsDelegate = AppLocalizations.delegate; + + @override + final List 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); +} diff --git a/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart b/packages/neon/neon_dashboard/lib/src/blocs/dashboard.dart new file mode 100644 index 00000000..9ae518b4 --- /dev/null +++ b/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>> 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>> widgets = BehaviorSubject(); + + @override + void dispose() { + _timer.cancel(); + unawaited(widgets.close()); + super.dispose(); + } + + @override + Future refresh() async { + widgets.add(widgets.valueOrNull?.asLoading() ?? Result.loading()); + + try { + final widgets = {}; + final v1WidgetIDs = []; + final v2WidgetIDs = []; + + 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; + } + } +} diff --git a/packages/neon/neon_dashboard/lib/src/options.dart b/packages/neon/neon_dashboard/lib/src/options.dart new file mode 100644 index 00000000..56264300 --- /dev/null +++ b/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 = []; + } +} diff --git a/packages/neon/neon_dashboard/lib/src/pages/main.dart b/packages/neon/neon_dashboard/lib/src/pages/main.dart new file mode 100644 index 00000000..2a19b6ad --- /dev/null +++ b/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(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, + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/neon/neon_dashboard/lib/src/routes.dart b/packages/neon/neon_dashboard/lib/src/routes.dart new file mode 100644 index 00000000..8d3d080c --- /dev/null +++ b/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( + 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(); +} diff --git a/packages/neon/neon_dashboard/lib/src/routes.g.dart b/packages/neon/neon_dashboard/lib/src/routes.g.dart new file mode 100644 index 00000000..8dae6752 --- /dev/null +++ b/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 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 push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/packages/neon/neon_dashboard/lib/src/utils/find.dart b/packages/neon/neon_dashboard/lib/src/utils/find.dart new file mode 100644 index 00000000..dccf391e --- /dev/null +++ b/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 { + /// 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); +} diff --git a/packages/neon/neon_dashboard/lib/src/widgets/widget.dart b/packages/neon/neon_dashboard/lib/src/widgets/widget.dart new file mode 100644 index 00000000..7e09e20b --- /dev/null +++ b/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), + ], + ), + ); + } +} diff --git a/packages/neon/neon_dashboard/lib/src/widgets/widget_button.dart b/packages/neon/neon_dashboard/lib/src/widgets/widget_button.dart new file mode 100644 index 00000000..4600d206 --- /dev/null +++ b/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, + ), + ); + } +} diff --git a/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart b/packages/neon/neon_dashboard/lib/src/widgets/widget_item.dart new file mode 100644 index 00000000..d0502e90 --- /dev/null +++ b/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, + ); + } +} diff --git a/packages/neon/neon_dashboard/pubspec.yaml b/packages/neon/neon_dashboard/pubspec.yaml new file mode 100644 index 00000000..ff86a386 --- /dev/null +++ b/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/ diff --git a/packages/neon/neon_dashboard/pubspec_overrides.yaml b/packages/neon/neon_dashboard/pubspec_overrides.yaml new file mode 100644 index 00000000..e247d2d9 --- /dev/null +++ b/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 diff --git a/packages/neon/neon_dashboard/test/find_test.dart b/packages/neon/neon_dashboard/test/find_test.dart new file mode 100644 index 00000000..4e046f94 --- /dev/null +++ b/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())); + expect(widgets.find(widget2.id), equals(widget2)); + }); + }); +} diff --git a/packages/neon/neon_dashboard/test/goldens/widget.png b/packages/neon/neon_dashboard/test/goldens/widget.png new file mode 100644 index 0000000000000000000000000000000000000000..dd66e350fcef529f27bd3ef1548d3b1f37291800 GIT binary patch literal 4792 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 MboFyt=akR{04m%s5dZ)H literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/test/goldens/widget_button_more.png b/packages/neon/neon_dashboard/test/goldens/widget_button_more.png new file mode 100644 index 0000000000000000000000000000000000000000..e71445d9366a100c98650eceaa9dbd3f9b99b8e3 GIT binary patch literal 3006 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 literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/test/goldens/widget_not_round.png b/packages/neon/neon_dashboard/test/goldens/widget_not_round.png new file mode 100644 index 0000000000000000000000000000000000000000..dd66e350fcef529f27bd3ef1548d3b1f37291800 GIT binary patch literal 4792 zcmeHLX;f2L621w61YDqD#DFMqr$q)t1u+C7g5by^VgP}VwBjHdQQEK=2#GL)w2a`^ zEucZ=C>n+YvBQ$cqBsy`i3?c-F|sR6CwAqIaZhISGesz(OPorn%9qiD@ANv zOT2fU4Ft z=iKQp!C}_e+8rX(*l&LaGW_mlMDr%%V5v@a96uup#ocBk6eRePE%tm2LZN>^6K z00-%9?&d%ei%inCCYXY&96T|)YOL+hNX1ZD`$1(jQ&3W3WNAQMzt+Y^+F6$loxu;^ zN?*cw)5jHxRNlaD*g;P$;)#ZZtSaraCU35e-C8+?#U#9|)nNsav`-nP02^ttC|sEY z6_-1hp{SQQgmNZVWieyQV^1bUMMZU<+mSS=gS9F2y}~_t^5p8G0mBdp5_Hq{u|G1E zj$mbnE(O6UK9oZqvb>Z*jNM-fQJ|(0C3yb zaNz4ci|VD@GM&tGy}i95VPO^tn&*XnK_uy!#dwfytL2f_HbLB)=?ymZ>TWKd*OmLKOpjFS(Y^>o6&s%mA(h@e-@Fq zUo2t^9TZLiM|gL@x`n4YvxDJK?0BrMb^B5 zSR7|c)|E{ZC1n^&_v6bAf7LJ;f+GZT+t*z=ytw9g0o0YzwoxgamV3+EwMR=$|1Odh zAOKB&5&xHo>i84wj~}gPPEEzj23%|6d7Mvg8BmPE{YbLZy{fzPHba|N2uW>CmKW2> zL9w034FSd&7g{@G(s4~HREtBd5abN=w5VwAfUVj`?y0n2?rv^`15Y?0$}1t0Pe~X1 z1W8&O`@;+i;r@6)lKy1To>2Zo*YYBjRJ+i!`^{h_m+w**$kMQi1bxwXK+OzBM7{2h z)J%wLE*E>UlB!i!G(-?(^B&4^_2rA%zszxXKXK{=yC+kBg_&w_C{_DA= ztDJVj-M8&nrAeHo&bljgj`N3t(OCGgxB&p{W(h)nqODT^yWVzsTHWA!=U71U2q%&A z;(|84b0#42`^Oo=7$qI@2OzE9z)KtuIWh5(H9p7Dc5uD6YS(H>#XMtxB_mh#j4DHl z7sQ9Z$6qD{sd~2-&Hll>oT{6|I$ky1`Q~(uYM^qmy4?S;Y$&vcIVBEZ4z4j=g96*m zFUV%wx|D|WcaZ8%)5pT33#<1pKmn{zAQ3tLio(S&_wLW7m3mW1wwC#9iUDd5lp^gT z8h({QpPr5B8PVsti?d*c=rHRk2hxY1q&*EEO zUWzYiUuKdTGYCL|ctK0{5c$$A!W@&>q_=9;5;07**#mbPI?;61!I6|2Ls-d^jS4&V zqKAlhmh$F>`Ku_E4#4_PKQw=L!j;2&@kka>LT>U6WFFAcp>^<{xUo@#x%syY!=0Wp z=9Xo^YXHAO>_|m`g6-^oBy|nE6(-bsthA-|dk5v;Ya2iVJ0Z$T{O|STspkBavL&fD z!*}IPGT&QgCBBR zW69E!{RDvXV$%=}zsVqLWq6T>6I>)1dS6S|+py6;c52a0XkUm*& zto*kSXKRrv%Ts=^HwlVTc-!U;4=|79at$d?0l!U5`mO&{wY6Y%i`ahTsc5MgG|vs5g!y!3q14YBJI2- zoc8$wjt;2rs0Wpr7W@d!9#d;aNQ~BmK~8}B(kD8BIrzT8k~Q)Ae(H#D@j&9e|1>k?a^bK_XEGlk&M-S3Z|KJ1*Hvno1;f lkV$?UIPe(aTM(-Crjcaeuh$bay(wf6kn5zH`3k zeV+Gu&v(8HULLNC^uEvo04&yfw^?F& zfW5$0ka}a>xrbH}x>i8gx~CI-vrc65Q+8(j>PP-y^|vj$q=klB!EFv3tUQ zscN@(D3Uck9dWE)e`9cim*Z2V>PTZk^+;pNxwj2=o=iu%65Eu(LQuU6V=u)fjrtma z97nM55iYBU0$gDh%a6s9uvz%yjtha8r3pIjWqePnqKn){B9Feh>$h*;c|)Bd6CWQR zY5SeS7+CHjyBfP}TwCxcyqa$ry1=O8`zadfz<-GETo1!LKk)=)J7)6-r8( zI^a>qgY-6@B%wdoeI8_dP1zyyh%N#@dJ- zfxs4&w(*w;doQ9tHlU9##0SWWXa=ezuU{X!q9FPCg}&hfpx>zXzM6RCKA`_FR)T&h zb%_Ijo3ZSVswt3vEGp^_j9?ma@_BDfi__bv62+`_xc^k^{WJf4^=B4}v@Y?=%Z;fu z1qB7Cxt$i#C@?A|LLX=n#|620JMqs}0(x%!?|F>b@2{XrFe|hJ+bcOYE62;{1MLr9 z;6F0#Y<7(7hYuf$)-cMD{Mt%q%4sw2#HEg^5M!ES^VQg)^rqluJBa=)_`k(xEu_$QM^BD+ zi`IDY&t>#PL%>hQ{dHuTAF)YSP(&vAk_V#O2}$-0fY`I_ zknL27!`<+{@d`eF%<@Z^r-{vDDw`TaJw>T6m5(k^B!su~d6n9&DrE(&a6TFv;O~YF;oFA9JDur$p3GtGmDDCoTVqN5b!|vFbmy zdHd|}3FnBQ!zEI4&s%M68saHN{@c%4e08e}b@00A_kb3 zfkU$0?GUHtWHEmt&(3@`gmi%)vCz`kgC)}ZG*v0n@oU^$PAp zINCJm(JoHAeZg?!*aO;uTUDz9j$`KmL34tETQeptyfyhT#vi89-b5$z`lyDEqxZNf znlWd7T12aDMCLF14Lx|KIw0`qE&5rWLb#6`M+qxu;yiGc8m$3x}@^{kDo>C^t z%6_XeSFh@~^qzV03>FUvg`_U{^_r9ttqlnWG_B&taxn1JSO-KN&GH9w(z>Zp0SMMFMQ@$xvZFmbVukcgW1?xY6~quDHz$mO#MevRR&CM{MpkinDSODMrEiV$`M}d3ignP%g+}DSgMahjs8mf_cB6uYTQkp#UkUDQv)DK}4S*Fz+fA^@ z*WKVVw8eR5Znu^X9!aau^A@yfS~!@U8-~@XdN>p$G&6ALnQDQ+^~iL-^1{?)nKk=y zV8$eJg{SIOsO7*aTidO+pL3m4AClw!rf#A!23E@0Nk|5WShIeRSI2Q-Cysc zv}+9_PX?FpCuUM916ipr+D#01g*fdsB4BE$mAGhejLE(tkhN~k5XH%=f$#aIn=Ack zltcNVja+8R@Ob4Q2RHRY;Z$##HMwA#mW?RZ%mf{IOh@t^wJN}}RpA*SlKKUNh^G8V zvhSZ5U>u0H_)ei}m#^kqBrJh4w2ZfZd8E#&z%X@ee4Virwvl{Mu|m~kNt*QaU~vD0r#Z{L)vS(F=N h8<4Y%|A%n6OVc9Ywirzs;Jjz??AY$HjpH16=I>HqXDt8# literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png b/packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png new file mode 100644 index 0000000000000000000000000000000000000000..9e65ba60b1f82b92c821cc3d7c99cc18853d4d2c GIT binary patch literal 4890 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 literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png b/packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png new file mode 100644 index 0000000000000000000000000000000000000000..5239eddf56a12893d91c7c9ea0f0527489fe1961 GIT binary patch literal 3746 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 LcHUL8laTmtx0@Ul literal 0 HcmV?d00001 diff --git a/packages/neon/neon_dashboard/test/widget_test.dart b/packages/neon/neon_dashboard/test/widget_test.dart new file mode 100644 index 00000000..6ec81bca --- /dev/null +++ b/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.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().having( + (final a) => a.onTap, + 'onTap is not null', + isNotNull, + ), + ); + expect(find.byType(NeonImageWrapper), findsOneWidget); + expect( + tester.widget(find.byType(NeonImageWrapper)), + isA().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().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().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.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.from([1, 2]).toBuilder() + ..reloadInterval = 0 + ..buttons = BuiltList.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().having( + (final a) => a.onTap, + 'onTap is not null', + isNotNull, + ), + ); + expect(find.byType(NeonImageWrapper), findsOneWidget); + expect( + tester.widget(find.byType(NeonImageWrapper)), + isA().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(), 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().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().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(), findsNothing); + + expect(find.byType(DashboardWidget), matchesGoldenFile('goldens/widget_without_buttons.png')); + }); + }); +}