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 00000000..13983ab7 Binary files /dev/null and b/packages/neon/neon_dashboard/assets/app.svg.vec differ 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 00000000..dd66e350 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png b/packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png new file mode 100644 index 00000000..15d7701d Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_button_invalid.png differ 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 00000000..e71445d9 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_button_more.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_button_new.png b/packages/neon/neon_dashboard/test/goldens/widget_button_new.png new file mode 100644 index 00000000..e71445d9 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_button_new.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_button_setup.png b/packages/neon/neon_dashboard/test/goldens/widget_button_setup.png new file mode 100644 index 00000000..e71445d9 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_button_setup.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_item.png b/packages/neon/neon_dashboard/test/goldens/widget_item.png new file mode 100644 index 00000000..c654ab85 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_item.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png b/packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png new file mode 100644 index 00000000..04d8782e Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_item_not_round.png differ 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 00000000..dd66e350 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_not_round.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_with_empty.png b/packages/neon/neon_dashboard/test/goldens/widget_with_empty.png new file mode 100644 index 00000000..3e6c6ad7 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_with_empty.png differ 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 00000000..9e65ba60 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png differ 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 00000000..5239eddf Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png differ diff --git a/packages/neon/neon_dashboard/test/goldens/widget_without_items.png b/packages/neon/neon_dashboard/test/goldens/widget_without_items.png new file mode 100644 index 00000000..996d04b1 Binary files /dev/null and b/packages/neon/neon_dashboard/test/goldens/widget_without_items.png differ 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')); + }); + }); +}