From fb887bec10958a824a2e6664fd27ee13c680c267 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 11 Mar 2024 11:02:29 +0800 Subject: [PATCH 1/6] fix: potential risk of being unable to launch URLs without HTTP(S) scheme (#4867) --- .../lib/core/helpers/url_launcher.dart | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index 28820b9968..91e1beac39 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -1,10 +1,10 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); @@ -16,35 +16,60 @@ Future afLaunchUrl( launcher.LaunchMode mode = launcher.LaunchMode.platformDefault, String? webOnlyWindowName, }) async { + // try to launch the uri directly + bool result; try { - return await launcher.launchUrl( - uri, - mode: mode, - webOnlyWindowName: webOnlyWindowName, - ); + result = await launcher.launchUrl(uri); } on PlatformException catch (e) { - Log.error("Failed to open uri: $e"); - if (onFailure != null) { - onFailure(uri); - } else { - showMessageToast( - LocaleKeys.failedToOpenUrl.tr(args: [e.message ?? "PlatformException"]), - context: context, - ); + Log.error('Failed to open uri: $e'); + } finally { + result = false; + } + + // if the uri is not a valid url, try to launch it with https scheme + final url = uri.toString(); + if (!result && !isURL(url, {'require_protocol': true})) { + try { + final uriWithScheme = Uri.parse('https://$url'); + result = await launcher.launchUrl(uriWithScheme); + } on PlatformException catch (e) { + Log.error('Failed to open uri: $e'); + if (context != null && context.mounted) { + _errorHandler(uri, context: context, onFailure: onFailure, e: e); + } } } - return false; + return result; } -Future afLaunchUrlString(String url) async { +Future afLaunchUrlString(String url) async { + final Uri uri; try { - final uri = Uri.parse(url); - - await launcher.launchUrl(uri); - } on PlatformException catch (e) { - Log.error("Failed to open uri: $e"); + uri = Uri.parse(url); } on FormatException catch (e) { - Log.error("Failed to parse url: $e"); + Log.error('Failed to parse url: $e'); + return false; + } + + // try to launch the uri directly + return afLaunchUrl(uri); +} + +void _errorHandler( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, + PlatformException? e, +}) { + Log.error('Failed to open uri: $e'); + + if (onFailure != null) { + onFailure(uri); + } else { + showMessageToast( + LocaleKeys.failedToOpenUrl.tr(args: [e?.message ?? "PlatformException"]), + context: context, + ); } } From f4048823430771161043922d70af31cd4daba45a Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 11 Mar 2024 11:55:52 +0800 Subject: [PATCH 2/6] fix: disable the shortcut for converting to numbered list when the block is a heading (#4868) --- frontend/appflowy_flutter/pubspec.lock | 4 ++-- frontend/appflowy_flutter/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index ad25f84ff6..a401564159 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: ce391a8 - resolved-ref: ce391a8c0f492f7b5fdd8f44bbc89fc68882ff23 + ref: cbd92c4 + resolved-ref: cbd92c4cd13844a5a34a73ef7614e8e79e374a16 url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "2.3.3" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index ec6409c9e6..d0d7de421b 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -167,7 +167,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "ce391a8" + ref: "cbd92c4" sheet: git: From b4c801188110a4aa0ccc65e56f6a88f23703e0b9 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:27:04 +0800 Subject: [PATCH 3/6] ci: fix flaky flutter integration tests (#4870) --- .../desktop/database/database_calendar_test.dart | 2 +- .../desktop/database/database_field_test.dart | 7 +++++-- .../integration_test/shared/common_operations.dart | 4 +--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart index adfb670fe2..d5916c627b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart @@ -82,7 +82,7 @@ void main() { // Hover over today's calendar cell await tester.hoverOnTodayCalendarCell( // Tap on create new event button - onHover: () async => tester.tapAddCalendarEventButton(), + onHover: tester.tapAddCalendarEventButton, ); // Make sure that the event editor popup is shown diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart index 22838adddd..30f79e1ac8 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_field_test.dart @@ -346,11 +346,14 @@ void main() { await tester.tapNewPropertyButton(); await tester.renameField(FieldType.LastEditedTime.i18n); await tester.tapSwitchFieldTypeButton(); + + // get time just before modifying + final modified = DateTime.now(); + + // create a last modified field (cont'd) await tester.selectFieldType(FieldType.LastEditedTime); await tester.dismissFieldEditor(); - final modified = DateTime.now(); - tester.assertCellContent( rowIndex: 0, fieldType: FieldType.CreatedTime, diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index ab9cbdc0d0..abfcb324f6 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -162,9 +162,7 @@ extension CommonOperations on WidgetTester { }) async { try { final gesture = await createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - await pump(); - await gesture.moveTo(offset ?? getCenter(finder)); + await gesture.addPointer(location: offset ?? getCenter(finder)); await pumpAndSettle(); await onHover?.call(); await gesture.removePointer(); From dd3f8b247a975f61158684aa8580b71b9903fa0f Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 11 Mar 2024 16:43:43 +0800 Subject: [PATCH 4/6] fix: only adding http(s) scheme if needed (#4871) --- .../lib/core/helpers/url_launcher.dart | 31 ++++++++++++++----- .../document/presentation/editor_style.dart | 10 +++--- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index 91e1beac39..c01d72a7fd 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -15,23 +15,34 @@ Future afLaunchUrl( OnFailureCallback? onFailure, launcher.LaunchMode mode = launcher.LaunchMode.platformDefault, String? webOnlyWindowName, + bool addingHttpSchemeWhenFailed = false, }) async { // try to launch the uri directly bool result; try { - result = await launcher.launchUrl(uri); + result = await launcher.launchUrl( + uri, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); } on PlatformException catch (e) { Log.error('Failed to open uri: $e'); } finally { result = false; } - // if the uri is not a valid url, try to launch it with https scheme + // if the uri is not a valid url, try to launch it with http scheme final url = uri.toString(); - if (!result && !isURL(url, {'require_protocol': true})) { + if (addingHttpSchemeWhenFailed && + !result && + !isURL(url, {'require_protocol': true})) { try { - final uriWithScheme = Uri.parse('https://$url'); - result = await launcher.launchUrl(uriWithScheme); + final uriWithScheme = Uri.parse('http://$url'); + result = await launcher.launchUrl( + uriWithScheme, + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); } on PlatformException catch (e) { Log.error('Failed to open uri: $e'); if (context != null && context.mounted) { @@ -43,7 +54,10 @@ Future afLaunchUrl( return result; } -Future afLaunchUrlString(String url) async { +Future afLaunchUrlString( + String url, { + bool addingHttpSchemeWhenFailed = false, +}) async { final Uri uri; try { uri = Uri.parse(url); @@ -53,7 +67,10 @@ Future afLaunchUrlString(String url) async { } // try to launch the uri directly - return afLaunchUrl(uri); + return afLaunchUrl( + uri, + addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, + ); } void _errorHandler( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 9095f1841e..ad851ed839 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,8 +1,5 @@ import 'dart:math'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; @@ -15,6 +12,8 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -294,7 +293,10 @@ class EditorStyleCustomizer { ..onTap = () { final editorState = context.read(); if (editorState.selection == null) { - afLaunchUrlString(href); + afLaunchUrlString( + href, + addingHttpSchemeWhenFailed: true, + ); return; } From 7afae69fe43770be519dc523dee64d5b51d0b2c9 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Mon, 11 Mar 2024 16:48:44 +0800 Subject: [PATCH 5/6] fix: release windows ci (#4873) --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9514748ae1..237f81d125 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,13 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + - name: Export pub environment variable on Windows + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + echo "PUB_CACHE=$LOCALAPPDATA\\Pub\\Cache" >> $GITHUB_ENV + fi + shell: bash + - name: Install flutter uses: subosito/flutter-action@v2 with: From c48001bd745fb2917f7f403d6675ac668acc6cf3 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:41:51 +0100 Subject: [PATCH 6/6] fix: launch review 0.5.1 (#4855) * feat: floating calculations row * fix: calculate cell overflow + tooltip * fix: empty text cell should be counted as empty * fix: empty text cell sohuld not be counted as not empty * fix: conversion of some field types for calculations * fix: tooltip + size of duplicate event button * fix: minor view meta info changes * fix: apply number format on word/char count * fix: dart format * fix: hide arrow for calc values --------- Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com> --- .../presentation/calendar_event_editor.dart | 25 ++- .../calculations/field_type_calc_ext.dart | 12 +- .../database/grid/presentation/grid_page.dart | 187 +++++++++++++----- .../widgets/calculations/calculate_cell.dart | 109 +++++++--- .../calculations/calculations_row.dart | 10 +- .../more_view_actions/more_view_actions.dart | 2 +- .../widgets/view_meta_info.dart | 13 +- .../scrolling/styled_scroll_bar.dart | 111 ++++++----- .../scrolling/styled_scrollview.dart | 78 +++----- frontend/resources/translations/en.json | 11 +- .../calculation/calculation_entities.rs | 7 +- .../src/services/calculations/service.rs | 69 ++++--- 12 files changed, 403 insertions(+), 231 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart index b701763a4a..70cedae16b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart @@ -20,6 +20,7 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalendarEventEditor extends StatelessWidget { @@ -86,16 +87,22 @@ class EventEditorControls extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - FlowyIconButton( - width: 20, - icon: const FlowySvg(FlowySvgs.m_duplicate_s), - iconColorOnHover: Theme.of(context).colorScheme.onSecondary, - onPressed: () => context.read().add( - CalendarEvent.duplicateEvent( - rowController.viewId, - rowController.rowId, + FlowyTooltip( + message: LocaleKeys.calendar_duplicateEvent.tr(), + child: FlowyIconButton( + width: 20, + icon: const FlowySvg( + FlowySvgs.m_duplicate_s, + size: Size.square(17), + ), + iconColorOnHover: Theme.of(context).colorScheme.onSecondary, + onPressed: () => context.read().add( + CalendarEvent.duplicateEvent( + rowController.viewId, + rowController.rowId, + ), ), - ), + ), ), const HSpace(8.0), FlowyIconButton( diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart index 698fe0ea76..8fbb40ffde 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/calculations/field_type_calc_ext.dart @@ -6,9 +6,15 @@ extension AvailableCalculations on FieldType { CalculationType.Count, ]; - // These FieldTypes cannot be empty, no need to count empty/non-empty - if (![FieldType.Checkbox, FieldType.LastEditedTime, FieldType.CreatedTime] - .contains(this)) { + // These FieldTypes cannot be empty, or might hold secondary + // data causing them to be seen as not empty when in fact they + // are empty. + if (![ + FieldType.URL, + FieldType.Checkbox, + FieldType.LastEditedTime, + FieldType.CreatedTime, + ].contains(this)) { calculationTypes.addAll([ CalculationType.CountEmpty, CalculationType.CountNonEmpty, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index 0d2a49e092..1c23939771 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -19,7 +19,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import '../../application/database_controller.dart'; -import '../../application/row/row_cache.dart'; import '../../application/row/row_controller.dart'; import '../../tab_bar/tab_bar_view.dart'; import '../../widgets/row/row_detail.dart'; @@ -259,7 +258,7 @@ class _GridHeader extends StatelessWidget { } } -class _GridRows extends StatelessWidget { +class _GridRows extends StatefulWidget { const _GridRows({ required this.viewId, required this.scrollController, @@ -268,6 +267,30 @@ class _GridRows extends StatelessWidget { final String viewId; final GridScrollController scrollController; + @override + State<_GridRows> createState() => _GridRowsState(); +} + +class _GridRowsState extends State<_GridRows> { + bool showFloatingCalculations = false; + + @override + void initState() { + super.initState(); + _evaluateFloatingCalculations(); + } + + void _evaluateFloatingCalculations() { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + // maxScrollExtent is 0.0 if scrolling is not possible + showFloatingCalculations = widget + .scrollController.verticalController.position.maxScrollExtent > + 0; + }); + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -275,24 +298,18 @@ class _GridRows extends StatelessWidget { builder: (context, state) { return Flexible( child: _WrapScrollView( - scrollController: scrollController, + scrollController: widget.scrollController, contentWidth: GridLayout.headerWidth(state.fields), - child: BlocBuilder( - buildWhen: (previous, current) => current.reason.maybeWhen( - reorderRows: () => true, - reorderSingleRow: (reorderRow, rowInfo) => true, - delete: (item) => true, - insert: (item) => true, - orElse: () => true, - ), + child: BlocConsumer( + listenWhen: (previous, current) => + previous.rowCount != current.rowCount, + listener: (context, state) => _evaluateFloatingCalculations(), builder: (context, state) { - final rowInfos = state.rowInfos; - final behavior = ScrollConfiguration.of(context).copyWith( - scrollbars: false, - ); return ScrollConfiguration( - behavior: behavior, - child: _renderList(context, state, rowInfos), + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + ), + child: _renderList(context, state), ); }, ), @@ -305,9 +322,8 @@ class _GridRows extends StatelessWidget { Widget _renderList( BuildContext context, GridState state, - List rowInfos, ) { - final children = rowInfos.mapIndexed((index, rowInfo) { + final children = state.rowInfos.mapIndexed((index, rowInfo) { return _renderRow( context, rowInfo.rowId, @@ -315,32 +331,56 @@ class _GridRows extends StatelessWidget { index: index, ); }).toList() - ..add(const GridRowBottomBar(key: Key('grid_footer'))) - ..add( - GridCalculationsRow( - key: const Key('grid_calculations'), - viewId: viewId, + ..add(const GridRowBottomBar(key: Key('grid_footer'))); + + if (showFloatingCalculations) { + children.add( + const SizedBox( + key: Key('calculations_bottom_padding'), + height: 36, ), ); + } else { + children.add( + GridCalculationsRow( + key: const Key('grid_calculations'), + viewId: widget.viewId, + ), + ); + } - return ReorderableListView.builder( - /// This is a workaround related to - /// https://github.com/flutter/flutter/issues/25652 - cacheExtent: 5000, - scrollController: scrollController.verticalController, - buildDefaultDragHandles: false, - proxyDecorator: (child, index, animation) => Material( - color: Colors.white.withOpacity(.1), - child: Opacity(opacity: .5, child: child), - ), - onReorder: (fromIndex, newIndex) { - final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; - if (fromIndex != toIndex) { - context.read().add(GridEvent.moveRow(fromIndex, toIndex)); - } - }, - itemCount: children.length, - itemBuilder: (context, index) => children[index], + children.add(const SizedBox(key: Key('footer_padding'), height: 10)); + + return Stack( + children: [ + Positioned.fill( + child: ReorderableListView.builder( + /// This is a workaround related to + /// https://github.com/flutter/flutter/issues/25652 + cacheExtent: 5000, + scrollController: widget.scrollController.verticalController, + physics: const ClampingScrollPhysics(), + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), + onReorder: (fromIndex, newIndex) { + final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; + if (fromIndex != toIndex) { + context + .read() + .add(GridEvent.moveRow(fromIndex, toIndex)); + } + }, + itemCount: children.length, + itemBuilder: (context, index) => children[index], + ), + ), + if (showFloatingCalculations) ...[ + _PositionedCalculationsRow(viewId: widget.viewId), + ], + ], ); } @@ -378,21 +418,16 @@ class _GridRows extends StatelessWidget { openDetailPage: (context, cellBuilder) { FlowyOverlay.show( context: context, - builder: (BuildContext context) { - return RowDetailPage( - rowController: rowController, - databaseController: databaseController, - ); - }, + builder: (_) => RowDetailPage( + rowController: rowController, + databaseController: databaseController, + ), ); }, ); if (animation != null) { - return SizeTransition( - sizeFactor: animation, - child: child, - ); + return SizeTransition(sizeFactor: animation, child: child); } return child; @@ -413,12 +448,14 @@ class _WrapScrollView extends StatelessWidget { @override Widget build(BuildContext context) { return ScrollbarListStack( + includeInsets: false, axis: Axis.vertical, controller: scrollController.verticalController, barSize: GridSize.scrollBarSize, autoHideScrollbar: false, child: StyledSingleChildScrollView( autoHideScrollbar: false, + includeInsets: false, controller: scrollController.horizontalController, axis: Axis.horizontal, child: SizedBox( @@ -429,3 +466,49 @@ class _WrapScrollView extends StatelessWidget { ); } } + +/// This Widget is used to show the Calculations Row at the bottom of the Grids ScrollView +/// when the ScrollView is scrollable. +/// +class _PositionedCalculationsRow extends StatefulWidget { + const _PositionedCalculationsRow({ + required this.viewId, + }); + + final String viewId; + + @override + State<_PositionedCalculationsRow> createState() => + _PositionedCalculationsRowState(); +} + +class _PositionedCalculationsRowState + extends State<_PositionedCalculationsRow> { + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + margin: EdgeInsets.only(left: GridSize.horizontalHeaderPadding), + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Theme.of(context).canvasColor, + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: SizedBox( + height: 36, + width: double.infinity, + child: GridCalculationsRow( + key: const Key('floating_grid_calculations'), + viewId: widget.viewId, + includeDefaultInsets: false, + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart index 2e73e72e12..c8199ce135 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculate_cell.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart'; @@ -16,6 +15,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dar import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class CalculateCell extends StatefulWidget { @@ -35,7 +35,38 @@ class CalculateCell extends StatefulWidget { } class _CalculateCellState extends State { + final _cellScrollController = ScrollController(); bool isSelected = false; + bool isScrollable = false; + + @override + void initState() { + super.initState(); + _checkScrollable(); + } + + @override + void didUpdateWidget(covariant CalculateCell oldWidget) { + _checkScrollable(); + super.didUpdateWidget(oldWidget); + } + + void _checkScrollable() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_cellScrollController.hasClients) { + setState( + () => + isScrollable = _cellScrollController.position.maxScrollExtent > 0, + ); + } + }); + } + + @override + void dispose() { + _cellScrollController.dispose(); + super.dispose(); + } void setIsSelected(bool selected) => setState(() => isSelected = selected); @@ -98,36 +129,62 @@ class _CalculateCellState extends State { Widget _showCalculateValue(BuildContext context, String? prefix) { prefix = prefix != null ? '$prefix ' : ''; + final calculateValue = + '$prefix${_withoutTrailingZeros(widget.calculation!.value)}'; - return FlowyButton( - radius: BorderRadius.zero, - hoverColor: AFThemeExtension.of(context).lightGreyHover, - text: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: FlowyText( - widget.calculation!.calculationType.shortLabel, - color: Theme.of(context).hintColor, - overflow: TextOverflow.ellipsis, + return FlowyTooltip( + message: !isScrollable ? "" : null, + richMessage: !isScrollable + ? null + : TextSpan( + children: [ + TextSpan( + text: widget.calculation!.calculationType.shortLabel + .toUpperCase(), + ), + const TextSpan(text: ' '), + TextSpan( + text: calculateValue, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ], ), - ), - if (widget.calculation!.value.isNotEmpty) ...[ - const HSpace(8), - Flexible( - child: FlowyText( - '$prefix${_withoutTrailingZeros(widget.calculation!.value)}', - color: AFThemeExtension.of(context).textColor, - overflow: TextOverflow.ellipsis, + child: FlowyButton( + radius: BorderRadius.zero, + hoverColor: AFThemeExtension.of(context).lightGreyHover, + text: Row( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _cellScrollController, + key: ValueKey(widget.calculation!.id), + reverse: true, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyText( + widget.calculation!.calculationType.shortLabel + .toUpperCase(), + color: Theme.of(context).hintColor, + fontSize: 10, + ), + if (widget.calculation!.value.isNotEmpty) ...[ + const HSpace(8), + FlowyText( + calculateValue, + color: AFThemeExtension.of(context).textColor, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), ), ), ], - const HSpace(8), - FlowySvg( - FlowySvgs.arrow_down_s, - color: Theme.of(context).hintColor, - ), - ], + ), ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart index 0d44e94190..9d9255e3a6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart @@ -7,9 +7,14 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations import 'package:flutter_bloc/flutter_bloc.dart'; class GridCalculationsRow extends StatelessWidget { - const GridCalculationsRow({super.key, required this.viewId}); + const GridCalculationsRow({ + super.key, + required this.viewId, + this.includeDefaultInsets = true, + }); final String viewId; + final bool includeDefaultInsets; @override Widget build(BuildContext context) { @@ -23,7 +28,8 @@ class GridCalculationsRow extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return Padding( - padding: GridSize.contentInsets, + padding: + includeDefaultInsets ? GridSize.contentInsets : EdgeInsets.zero, child: Row( children: [ ...state.fields.map( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart index 1ae2ff35c3..c8634b3df5 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/more_view_actions.dart @@ -66,7 +66,7 @@ class _MoreViewActionsState extends State { builder: (context, state) { return AppFlowyPopover( mutex: popoverMutex, - constraints: BoxConstraints.loose(const Size(210, 400)), + constraints: BoxConstraints.loose(const Size(215, 400)), offset: const Offset(0, 30), popupBuilder: (_) { final actions = [ diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart index f290286097..a5a72964af 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/view_meta_info.dart @@ -24,6 +24,8 @@ class ViewMetaInfo extends StatelessWidget { @override Widget build(BuildContext context) { + final numberFormat = NumberFormat(); + // If more info is added to this Widget, use a separated ListView return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), @@ -33,15 +35,21 @@ class ViewMetaInfo extends StatelessWidget { if (documentCounters != null) ...[ FlowyText.regular( LocaleKeys.moreAction_wordCount.tr( - args: [documentCounters!.wordCount.toString()], + args: [ + numberFormat.format(documentCounters!.wordCount).toString(), + ], ), + fontSize: 11, color: Theme.of(context).hintColor, ), const VSpace(2), FlowyText.regular( LocaleKeys.moreAction_charCount.tr( - args: [documentCounters!.charCount.toString()], + args: [ + numberFormat.format(documentCounters!.charCount).toString(), + ], ), + fontSize: 11, color: Theme.of(context).hintColor, ), ], @@ -51,6 +59,7 @@ class ViewMetaInfo extends StatelessWidget { LocaleKeys.moreAction_createdAt.tr( args: [dateFormat.formatDate(createdAt!, true, timeFormat)], ), + fontSize: 11, maxLines: 2, color: Theme.of(context).hintColor, ), diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart index b3326d0e60..da22674152 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scroll_bar.dart @@ -1,13 +1,28 @@ -import 'dart:math'; import 'dart:async'; -import 'package:async/async.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; +import 'dart:math'; + import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/widget/mouse_hover_builder.dart'; import 'package:styled_widget/styled_widget.dart'; class StyledScrollbar extends StatefulWidget { + const StyledScrollbar({ + super.key, + this.size, + required this.axis, + required this.controller, + this.onDrag, + this.contentSize, + this.showTrack = false, + this.autoHideScrollbar = true, + this.handleColor, + this.trackColor, + }); + final double? size; final Axis axis; final ScrollController controller; @@ -22,18 +37,6 @@ class StyledScrollbar extends StatefulWidget { // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents final double? contentSize; - const StyledScrollbar( - {super.key, - this.size, - required this.axis, - required this.controller, - this.onDrag, - this.contentSize, - this.showTrack = false, - this.autoHideScrollbar = true, - this.handleColor, - this.trackColor}); - @override ScrollbarState createState() => ScrollbarState(); } @@ -46,25 +49,29 @@ class ScrollbarState extends State { @override void initState() { super.initState(); - widget.controller.addListener(() => setState(() {})); - - widget.controller.position.isScrollingNotifier.addListener( - _hideScrollbarInTime, - ); + widget.controller.addListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .addListener(_hideScrollbarInTime); } @override - void didUpdateWidget(StyledScrollbar oldWidget) { - if (oldWidget.contentSize != widget.contentSize) setState(() {}); - super.didUpdateWidget(oldWidget); + void dispose() { + if (widget.controller.hasClients) { + widget.controller.removeListener(_onScrollChanged); + widget.controller.position.isScrollingNotifier + .removeListener(_hideScrollbarInTime); + } + super.dispose(); } + void _onScrollChanged() => setState(() {}); + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (_, BoxConstraints constraints) { double maxExtent; - final contentSize = widget.contentSize; + final double? contentSize = widget.contentSize; switch (widget.axis) { case Axis.vertical: @@ -109,11 +116,9 @@ class ScrollbarState extends State { } // Hide the handle if content is < the viewExtent - var showHandle = contentExtent > _viewExtent && contentExtent > 0; - - if (hideHandler) { - showHandle = false; - } + var showHandle = hideHandler + ? false + : contentExtent > _viewExtent && contentExtent > 0; // Handle color var handleColor = widget.handleColor ?? @@ -184,7 +189,7 @@ class ScrollbarState extends State { if (!widget.controller.position.isScrollingNotifier.value) { _hideScrollbarOperation = CancelableOperation.fromFuture( - Future.delayed(const Duration(seconds: 2), () {}), + Future.delayed(const Duration(seconds: 2)), ).then((_) { hideHandler = true; if (mounted) { @@ -216,17 +221,6 @@ class ScrollbarState extends State { } class ScrollbarListStack extends StatelessWidget { - final double barSize; - final Axis axis; - final Widget child; - final ScrollController controller; - final double? contentSize; - final EdgeInsets? scrollbarPadding; - final Color? handleColor; - final Color? trackColor; - final bool showTrack; - final bool autoHideScrollbar; - const ScrollbarListStack({ super.key, required this.barSize, @@ -239,20 +233,37 @@ class ScrollbarListStack extends StatelessWidget { this.autoHideScrollbar = true, this.trackColor, this.showTrack = false, + this.includeInsets = true, }); + final double barSize; + final Axis axis; + final Widget child; + final ScrollController controller; + final double? contentSize; + final EdgeInsets? scrollbarPadding; + final Color? handleColor; + final Color? trackColor; + final bool showTrack; + final bool autoHideScrollbar; + final bool includeInsets; + @override Widget build(BuildContext context) { return Stack( children: [ - /// LIST - /// Wrap with a bit of padding on the right - child.padding( - right: axis == Axis.vertical ? barSize + Insets.m : 0, - bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, + /// Wrap with a bit of padding on the right or bottom to make room for the scrollbar + Padding( + padding: !includeInsets + ? EdgeInsets.zero + : EdgeInsets.only( + right: axis == Axis.vertical ? barSize + Insets.m : 0, + bottom: axis == Axis.horizontal ? barSize + Insets.m : 0, + ), + child: child, ), - /// SCROLLBAR + /// Display the scrollbar Padding( padding: scrollbarPadding ?? EdgeInsets.zero, child: StyledScrollbar( @@ -266,7 +277,7 @@ class ScrollbarListStack extends StatelessWidget { showTrack: showTrack, ), ) - // The animate will be used by the children that using styled_widget. + // The animate will be used by the children that are using styled_widget. .animate(const Duration(milliseconds: 250), Curves.easeOut), ], ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart index 664dbc7ed1..b1b7c8afc7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart @@ -4,17 +4,6 @@ import 'styled_list.dart'; import 'styled_scroll_bar.dart'; class StyledSingleChildScrollView extends StatefulWidget { - final double? contentSize; - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? controller; - final EdgeInsets? scrollbarPadding; - final double barSize; - final bool autoHideScrollbar; - - final Widget? child; - const StyledSingleChildScrollView({ super.key, required this.child, @@ -26,8 +15,20 @@ class StyledSingleChildScrollView extends StatefulWidget { this.scrollbarPadding, this.barSize = 8, this.autoHideScrollbar = true, + this.includeInsets = true, }); + final Widget? child; + final double? contentSize; + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? controller; + final EdgeInsets? scrollbarPadding; + final double barSize; + final bool autoHideScrollbar; + final bool includeInsets; + @override State createState() => StyledSingleChildScrollViewState(); @@ -35,13 +36,8 @@ class StyledSingleChildScrollView extends StatefulWidget { class StyledSingleChildScrollViewState extends State { - late ScrollController scrollController; - - @override - void initState() { - scrollController = widget.controller ?? ScrollController(); - super.initState(); - } + late final ScrollController scrollController = + widget.controller ?? ScrollController(); @override void dispose() { @@ -51,14 +47,6 @@ class StyledSingleChildScrollViewState super.dispose(); } - @override - void didUpdateWidget(StyledSingleChildScrollView oldWidget) { - if (oldWidget.child != widget.child) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } - @override Widget build(BuildContext context) { return ScrollbarListStack( @@ -70,6 +58,7 @@ class StyledSingleChildScrollViewState barSize: widget.barSize, trackColor: widget.trackColor, handleColor: widget.handleColor, + includeInsets: widget.includeInsets, child: SingleChildScrollView( scrollDirection: widget.axis, physics: StyledScrollPhysics(), @@ -81,13 +70,6 @@ class StyledSingleChildScrollViewState } class StyledCustomScrollView extends StatefulWidget { - final Axis axis; - final Color? trackColor; - final Color? handleColor; - final ScrollController? verticalController; - final List slivers; - final double barSize; - const StyledCustomScrollView({ super.key, this.axis = Axis.vertical, @@ -98,32 +80,20 @@ class StyledCustomScrollView extends StatefulWidget { this.barSize = 8, }); + final Axis axis; + final Color? trackColor; + final Color? handleColor; + final ScrollController? verticalController; + final List slivers; + final double barSize; + @override StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); } class StyledCustomScrollViewState extends State { - late ScrollController controller; - - @override - void initState() { - controller = widget.verticalController ?? ScrollController(); - - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - void didUpdateWidget(StyledCustomScrollView oldWidget) { - if (oldWidget.slivers != widget.slivers) { - setState(() {}); - } - super.didUpdateWidget(oldWidget); - } + late final ScrollController controller = + widget.verticalController ?? ScrollController(); @override Widget build(BuildContext context) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2e60ac8be7..fb50bf0998 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -93,7 +93,7 @@ "moreOptions": "More options", "wordCount": "Word count: {}", "charCount": "Character count: {}", - "createdAt": "Created at: {}", + "createdAt": "Created: {}", "deleteView": "Delete", "duplicateView": "Duplicate" }, @@ -743,9 +743,9 @@ "sum": "Sum", "count": "Count", "countEmpty": "Count empty", - "countEmptyShort": "Empty", - "countNonEmpty": "Count non empty", - "countNonEmptyShort": "Not empty" + "countEmptyShort": "EMPTY", + "countNonEmpty": "Count not empty", + "countNonEmptyShort": "FILLED" } }, "document": { @@ -1051,7 +1051,8 @@ "name": "Calendar settings" }, "referencedCalendarPrefix": "View of", - "quickJumpYear": "Jump to" + "quickJumpYear": "Jump to", + "duplicateEvent": "Duplicate event" }, "errorDialog": { "title": "AppFlowy Error", diff --git a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs index c45b567640..8137b905d9 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/calculation/calculation_entities.rs @@ -116,8 +116,13 @@ impl CalculationType { | CalculationType::Sum => { matches!(field_type, FieldType::Number) }, + // Exclude some fields from CountNotEmpty & CountEmpty + CalculationType::CountEmpty | CalculationType::CountNonEmpty => !matches!( + field_type, + FieldType::URL | FieldType::Checkbox | FieldType::CreatedTime | FieldType::LastEditedTime + ), // All fields - CalculationType::Count | CalculationType::CountEmpty | CalculationType::CountNonEmpty => true, + CalculationType::Count => true, } } } diff --git a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs index dda8a68f3e..dad31a12f0 100644 --- a/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs +++ b/frontend/rust-lib/flowy-database2/src/services/calculations/service.rs @@ -27,8 +27,8 @@ impl CalculationsService { CalculationType::Min => self.calculate_min(field, row_cells), CalculationType::Sum => self.calculate_sum(field, row_cells), CalculationType::Count => self.calculate_count(row_cells), - CalculationType::CountEmpty => self.calculate_count_empty(row_cells), - CalculationType::CountNonEmpty => self.calculate_count_non_empty(row_cells), + CalculationType::CountEmpty => self.calculate_count_empty(field, row_cells), + CalculationType::CountNonEmpty => self.calculate_count_non_empty(field, row_cells), } } @@ -129,34 +129,51 @@ impl CalculationsService { } } - fn calculate_count_empty(&self, row_cells: Vec>) -> String { - if !row_cells.is_empty() { - format!( - "{}", - row_cells - .iter() - .filter(|c| c.is_none()) - .collect::>() - .len() - ) - } else { - String::new() + fn calculate_count_empty(&self, field: &Field, row_cells: Vec>) -> String { + let field_type = FieldType::from(field.field_type); + if let Some(handler) = TypeOptionCellExt::new_with_cell_data_cache(field, None) + .get_type_option_cell_data_handler(&field_type) + { + if !row_cells.is_empty() { + return format!( + "{}", + row_cells + .iter() + .filter(|c| c.is_none() + || handler + .handle_stringify_cell(&c.cell.clone().unwrap_or_default(), &field_type, field) + .is_empty()) + .collect::>() + .len() + ); + } } + + String::new() } - fn calculate_count_non_empty(&self, row_cells: Vec>) -> String { - if !row_cells.is_empty() { - format!( - "{}", - row_cells - .iter() - .filter(|c| c.is_some()) - .collect::>() - .len() - ) - } else { - String::new() + fn calculate_count_non_empty(&self, field: &Field, row_cells: Vec>) -> String { + let field_type = FieldType::from(field.field_type); + if let Some(handler) = TypeOptionCellExt::new_with_cell_data_cache(field, None) + .get_type_option_cell_data_handler(&field_type) + { + if !row_cells.is_empty() { + return format!( + "{}", + row_cells + .iter() + // Check the Cell has data and that the stringified version is not empty + .filter(|c| c.is_some() + && !handler + .handle_stringify_cell(&c.cell.clone().unwrap_or_default(), &field_type, field) + .is_empty()) + .collect::>() + .len() + ); + } } + + String::new() } fn reduce_values_f64(&self, field: &Field, row_cells: Vec>, f: F) -> T