diff --git a/packages/neon/neon_dashboard/lib/src/pages/main.dart b/packages/neon/neon_dashboard/lib/src/pages/main.dart index d06b21e2d87..fd62974322c 100644 --- a/packages/neon/neon_dashboard/lib/src/pages/main.dart +++ b/packages/neon/neon_dashboard/lib/src/pages/main.dart @@ -1,9 +1,17 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:neon/blocs.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/blocs/dashboard.dart'; +import 'package:neon_dashboard/src/widgets/dry_intrinsic_height.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; /// Displays the whole dashboard page layout. class DashboardMainPage extends StatelessWidget { @@ -21,15 +29,37 @@ class DashboardMainPage extends StatelessWidget { builder: (final context, final snapshot) { Widget? child; if (snapshot.hasData) { + var minHeight = 504.0; + + final children = []; + for (final widget in snapshot.requireData.entries) { + final items = buildWidgetItems( + context: context, + widget: widget.key, + items: widget.value, + ).toList(); + + final height = items.map((final i) => i.height!).reduce((final a, final b) => a + b); + minHeight = max(minHeight, height); + + children.add( + DashboardWidget( + widget: widget.key, + children: items, + ), + ); + } + child = Wrap( alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, - children: snapshot.requireData.entries + children: children .map( - (final widget) => DashboardWidget( - widget: widget.key, - items: widget.value, + (final widget) => SizedBox( + width: 320, + height: minHeight + 24, + child: widget, ), ) .toList(), @@ -53,4 +83,100 @@ class DashboardMainPage extends StatelessWidget { }, ); } + + /// Builds the list of messages, [items] and buttons for a [widget]. + @visibleForTesting + static Iterable buildWidgetItems({ + required final BuildContext context, + required final dashboard.Widget widget, + required final dashboard.WidgetItems? items, + }) sync* { + yield SizedBox( + height: 64, + child: DryIntrinsicHeight( + child: 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), + ), + ), + ), + ), + ); + + yield const SizedBox( + height: 20, + ); + + final halfEmptyContentMessage = _buildMessage(items?.halfEmptyContentMessage); + final emptyContentMessage = _buildMessage(items?.emptyContentMessage); + if (halfEmptyContentMessage != null) { + yield halfEmptyContentMessage; + } + if (emptyContentMessage != null) { + yield emptyContentMessage; + } + if (halfEmptyContentMessage == null && emptyContentMessage == null && (items?.items.isEmpty ?? true)) { + yield _buildMessage(DashboardLocalizations.of(context).noEntries)!; + } + + if (items?.items != null) { + for (final item in items!.items) { + yield SizedBox( + height: 64, + child: DryIntrinsicHeight( + child: DashboardWidgetItem( + item: item, + roundIcon: widget.itemIconsRound, + ), + ), + ); + } + } + + yield const SizedBox( + height: 20, + ); + + if (widget.buttons != null) { + for (final button in widget.buttons!) { + yield SizedBox( + height: 32, + child: DashboardWidgetButton( + button: button, + ), + ); + } + } + } + + static SizedBox? _buildMessage(final String? message) { + if (message == null || message.isEmpty) { + return null; + } + + return SizedBox( + height: 60, + child: Center( + child: Column( + children: [ + const Icon( + Icons.check, + size: largeIconSize, + ), + Text(message), + ], + ), + ), + ); + } } diff --git a/packages/neon/neon_dashboard/lib/src/widgets/dry_intrinsic_height.dart b/packages/neon/neon_dashboard/lib/src/widgets/dry_intrinsic_height.dart new file mode 100644 index 00000000000..896a07f9dee --- /dev/null +++ b/packages/neon/neon_dashboard/lib/src/widgets/dry_intrinsic_height.dart @@ -0,0 +1,33 @@ +// ignore_for_file: public_member_api_docs +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// NOTE ref: https://github.com/flutter/flutter/issues/71687 & https://gist.github.com/matthew-carroll/65411529a5fafa1b527a25b7130187c6 +/// Same as `IntrinsicHeight` except that when this widget is instructed +/// to `computeDryLayout()`, it doesn't invoke that on its child, instead +/// it computes the child's intrinsic height. +/// +/// This widget is useful in situations where the `child` does not +/// support dry layout, e.g., `TextField` as of 01/02/2021. +class DryIntrinsicHeight extends SingleChildRenderObjectWidget { + const DryIntrinsicHeight({ + super.key, + super.child, + }); + + @override + RenderDryIntrinsicHeight createRenderObject(final BuildContext context) => RenderDryIntrinsicHeight(); +} + +class RenderDryIntrinsicHeight extends RenderIntrinsicHeight { + @override + Size computeDryLayout(final BoxConstraints constraints) { + if (child != null) { + final height = child!.computeMinIntrinsicHeight(constraints.maxWidth); + final width = child!.computeMinIntrinsicWidth(height); + return Size(width, height); + } else { + return Size.zero; + } + } +} diff --git a/packages/neon/neon_dashboard/lib/src/widgets/widget.dart b/packages/neon/neon_dashboard/lib/src/widgets/widget.dart index d2559c62a30..3680a3c598b 100644 --- a/packages/neon/neon_dashboard/lib/src/widgets/widget.dart +++ b/packages/neon/neon_dashboard/lib/src/widgets/widget.dart @@ -1,10 +1,5 @@ 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. @@ -12,7 +7,7 @@ class DashboardWidget extends StatelessWidget { /// Creates a new dashboard widget items. const DashboardWidget({ required this.widget, - required this.items, + required this.children, super.key, }); @@ -20,83 +15,19 @@ class DashboardWidget extends StatelessWidget { final dashboard.Widget widget; /// The items of the widget to be displayed. - final dashboard.WidgetItems? items; + final List children; @override - Widget build(final BuildContext context) { - final halfEmptyContentMessage = _renderMessage(items?.halfEmptyContentMessage); - final emptyContentMessage = _renderMessage(items?.emptyContentMessage); - - return SizedBox( - width: 320, - height: 560, - child: Card( + Widget build(final BuildContext context) => Card( child: InkWell( onTap: widget.widgetUrl != null && widget.widgetUrl!.isNotEmpty ? () => context.go(widget.widgetUrl!) : null, borderRadius: const BorderRadius.all(Radius.circular(12)), - child: ListView( + child: Padding( padding: const EdgeInsets.all(8), - key: PageStorageKey('dashboard-${widget.id}'), - 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(DashboardLocalizations.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, - ), - ), - ], + child: Column( + children: children, + ), ), ), - ), - ); - } - - 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/test/goldens/widget.png b/packages/neon/neon_dashboard/test/goldens/widget.png index 614120d9d5e..3b27e2ab14d 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget.png and b/packages/neon/neon_dashboard/test/goldens/widget.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 index e942e89ca58..16e884f2d0f 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget_not_round.png 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 index 8460bc58915..d3bd285b229 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget_with_empty.png 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 index 2028f4f81ee..3761f377c96 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget_with_half_empty.png 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 index 409ce159d90..1fa76c61b41 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget_without_buttons.png 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 index 6b0c3927e99..2672129e95a 100644 Binary files a/packages/neon/neon_dashboard/test/goldens/widget_without_items.png 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 index 045b6f45db4..8ed3bc11928 100644 --- a/packages/neon/neon_dashboard/test/widget_test.dart +++ b/packages/neon/neon_dashboard/test/widget_test.dart @@ -9,6 +9,7 @@ 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/pages/main.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'; @@ -263,9 +264,15 @@ void main() { await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget, - items: items, + Builder( + builder: (final context) => DashboardWidget( + widget: widget, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widget, + items: items, + ).toList(), + ), ), ), ); @@ -299,12 +306,19 @@ void main() { }); testWidgets('Without widgetUrl', (final tester) async { + final widgetEmptyURL = widget.rebuild((final b) => b.widgetUrl = ''); await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget.rebuild((final b) => b.widgetUrl = ''), - items: items, + Builder( + builder: (final context) => DashboardWidget( + widget: widgetEmptyURL, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widgetEmptyURL, + items: items, + ).toList(), + ), ), ), ); @@ -320,12 +334,19 @@ void main() { }); testWidgets('Not round', (final tester) async { + final widgetNotRound = widget.rebuild((final b) => b.itemIconsRound = false); await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget.rebuild((final b) => b.itemIconsRound = false), - items: items, + Builder( + builder: (final context) => DashboardWidget( + widget: widgetNotRound, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widgetNotRound, + items: items, + ).toList(), + ), ), ), ); @@ -346,9 +367,15 @@ void main() { await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget, - items: items.rebuild((final b) => b.halfEmptyContentMessage = 'Half empty'), + Builder( + builder: (final context) => DashboardWidget( + widget: widget, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widget, + items: items.rebuild((final b) => b.halfEmptyContentMessage = 'Half empty'), + ).toList(), + ), ), ), ); @@ -363,9 +390,15 @@ void main() { await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget, - items: items.rebuild((final b) => b.emptyContentMessage = 'Empty'), + Builder( + builder: (final context) => DashboardWidget( + widget: widget, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widget, + items: items.rebuild((final b) => b.emptyContentMessage = 'Empty'), + ).toList(), + ), ), ), ); @@ -380,9 +413,15 @@ void main() { await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget, - items: null, + Builder( + builder: (final context) => DashboardWidget( + widget: widget, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widget, + items: null, + ).toList(), + ), ), ), ); @@ -394,12 +433,19 @@ void main() { }); testWidgets('Without buttons', (final tester) async { + final widgetWithoutButtons = widget.rebuild((final b) => b.buttons.clear()); await tester.pumpWidget( wrapWidget( accountsBloc, - DashboardWidget( - widget: widget.rebuild((final b) => b.buttons.clear()), - items: items, + Builder( + builder: (final context) => DashboardWidget( + widget: widgetWithoutButtons, + children: DashboardMainPage.buildWidgetItems( + context: context, + widget: widgetWithoutButtons, + items: null, + ).toList(), + ), ), ), );