From 10d48e6a55a37b3ed937be2bf774049d277abb6f Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 7 Apr 2023 18:04:56 +0200 Subject: [PATCH] feat: Replaced old label form fields with full page search, removed badge from edit button in document details --- android/build.gradle | 2 +- lib/core/repository/label_repository.dart | 4 +- ...orm_builder_relative_date_range_field.dart | 6 +- .../material/search/search_anchor.dart | 1885 +++++++++++++++++ lib/core/widgets/offline_banner.dart | 2 +- .../widgets/bulk_edit_label_bottom_sheet.dart | 5 +- .../cubit/document_details_cubit.dart | 6 +- .../view/pages/document_details_page.dart | 29 +- .../cubit/document_edit_cubit.dart | 25 +- .../cubit/document_edit_cubit.freezed.dart | 20 +- .../cubit/document_edit_state.dart | 8 +- .../view/document_edit_page.dart | 270 ++- .../cubit/document_search_cubit.dart | 22 +- .../view/sliver_search_bar.dart | 5 +- .../document_upload_preparation_page.dart | 31 +- .../documents/cubit/documents_cubit.dart | 7 +- .../documents/cubit/documents_state.dart | 22 +- .../view/widgets/adaptive_documents_view.dart | 2 +- .../widgets/items/document_list_item.dart | 13 +- .../widgets/search/document_filter_form.dart | 15 +- .../edit_label/cubit/edit_label_cubit.dart | 15 +- .../edit_label/view/add_label_page.dart | 1 + .../edit_label/view/edit_label_page.dart | 1 + .../view/impl/edit_correspondent_page.dart | 19 +- .../view/impl/edit_document_type_page.dart | 2 +- .../view/impl/edit_storage_path_page.dart | 2 +- .../edit_label/view/impl/edit_tag_page.dart | 2 +- lib/features/edit_label/view/label_form.dart | 17 +- lib/features/inbox/cubit/inbox_cubit.dart | 2 + lib/features/inbox/view/pages/inbox_page.dart | 12 +- lib/features/labels/cubit/label_cubit.dart | 12 - .../labels/cubit/label_cubit_mixin.dart | 17 +- ...rage_path_autofill_form_builder_field.dart | 9 +- .../view/widgets/fullscreen_label_form.dart | 264 +++ .../labels/view/widgets/label_form_field.dart | 297 ++- .../cubit/linked_documents_state.dart | 4 + .../server_address_form_field.dart | 19 +- .../user_credentials_form_field.dart | 18 +- .../saved_view/cubit/saved_view_cubit.dart | 14 +- .../saved_view/view/add_saved_view_page.dart | 9 +- .../saved_view/view/saved_view_list.dart | 21 +- .../cubit/saved_view_details_cubit.dart | 27 +- .../search_app_bar/view/search_app_bar.dart | 7 +- lib/l10n/intl_cs.arb | 4 + lib/l10n/intl_de.arb | 4 + lib/l10n/intl_en.arb | 4 + lib/l10n/intl_fr.arb | 4 + lib/l10n/intl_pl.arb | 4 + lib/l10n/intl_ru.arb | 701 ++++++ lib/l10n/intl_tr.arb | 4 + lib/main.dart | 3 +- lib/routes/document_details_route.dart | 3 +- .../converters/hex_color_json_converter.dart | 2 +- packages/paperless_api/pubspec.yaml | 2 +- .../animated_touch_bubble_part.dart | 3 +- .../lib/edge_detection_shape/magnifier.dart | 3 +- pubspec.lock | 28 +- pubspec.yaml | 5 +- 58 files changed, 3457 insertions(+), 487 deletions(-) create mode 100644 lib/core/widgets/material/search/search_anchor.dart create mode 100644 lib/features/labels/view/widgets/fullscreen_label_form.dart create mode 100644 lib/l10n/intl_ru.arb diff --git a/android/build.gradle b/android/build.gradle index 02cd41a..6572cbe 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -28,6 +28,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/lib/core/repository/label_repository.dart b/lib/core/repository/label_repository.dart index 074de84..18febc7 100644 --- a/lib/core/repository/label_repository.dart +++ b/lib/core/repository/label_repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository_state.dart'; @@ -14,8 +15,8 @@ class LabelRepository extends HydratedCubit { Object source, { required void Function(LabelRepositoryState) onChanged, }) { + onChanged(state); _subscribers.putIfAbsent(source, () { - onChanged(state); return stream.listen((event) => onChanged(event)); }); } @@ -26,6 +27,7 @@ class LabelRepository extends HydratedCubit { } Future initialize() { + debugPrint("Initializing labels..."); return Future.wait([ findAllCorrespondents(), findAllDocumentTypes(), diff --git a/lib/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart b/lib/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart index 9d85a4d..9cf4913 100644 --- a/lib/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart +++ b/lib/core/widgets/form_builder_fields/extended_date_range_form_field/form_builder_relative_date_range_field.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; + import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; @@ -56,7 +56,9 @@ class _FormBuilderRelativeDateRangePickerState inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - validator: FormBuilderValidators.numeric(), + // validator: (value) { //TODO: Check if this is required + // do numeric validation + // }, keyboardType: TextInputType.number, onChanged: (value) { final parsed = int.tryParse(value); diff --git a/lib/core/widgets/material/search/search_anchor.dart b/lib/core/widgets/material/search/search_anchor.dart new file mode 100644 index 0000000..1bfd31a --- /dev/null +++ b/lib/core/widgets/material/search/search_anchor.dart @@ -0,0 +1,1885 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// TODO: Remove once these changes were merged into stable release. +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +const int _kOpenViewMilliseconds = 600; +const Duration _kOpenViewDuration = + Duration(milliseconds: _kOpenViewMilliseconds); +const Duration _kAnchorFadeDuration = Duration(milliseconds: 150); +const Curve _kViewFadeOnInterval = Interval(0.0, 1 / 2); +const Curve _kViewIconsFadeOnInterval = Interval(1 / 6, 2 / 6); +const Curve _kViewDividerFadeOnInterval = Interval(0.0, 1 / 6); +const Curve _kViewListFadeOnInterval = + Interval(133 / _kOpenViewMilliseconds, 233 / _kOpenViewMilliseconds); + +/// Signature for a function that creates a [Widget] which is used to open a search view. +/// +/// The `controller` callback provided to [SearchAnchor.builder] can be used +/// to open the search view and control the editable field on the view. +typedef SearchAnchorChildBuilder = Widget Function( + BuildContext context, SearchController controller); + +/// Signature for a function that creates a [Widget] to build the suggestion list +/// based on the input in the search bar. +/// +/// The `controller` callback provided to [SearchAnchor.suggestionsBuilder] can be used +/// to close the search view and control the editable field on the view. +typedef SuggestionsBuilder = Iterable Function( + BuildContext context, SearchController controller); + +/// Signature for a function that creates a [Widget] to layout the suggestion list. +/// +/// Parameter `suggestions` is the content list that this function wants to lay out. +typedef ViewBuilder = Widget Function(Iterable suggestions); + +/// Manages a "search view" route that allows the user to select one of the +/// suggested completions for a search query. +/// +/// The search view's route can either be shown by creating a [SearchController] +/// and then calling [SearchController.openView] or by tapping on an anchor. +/// When the anchor is tapped or [SearchController.openView] is called, the search view either +/// grows to a specific size, or grows to fill the entire screen. By default, +/// the search view only shows full screen on mobile platforms. Use [SearchAnchor.isFullScreen] +/// to override the default setting. +/// +/// The search view is usually opened by a [SearchBar], an [IconButton] or an [Icon]. +/// If [builder] returns an Icon, or any un-tappable widgets, we don't have +/// to explicitly call [SearchController.openView]. +/// +/// {@tool dartpad} +/// This example shows how to use an IconButton to open a search view in a [SearchAnchor]. +/// It also shows how to use [SearchController] to open or close the search view route. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to set up a floating (or pinned) AppBar with a +/// [SearchAnchor] for a title. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SearchBar], a widget that defines a search bar. +/// * [SearchBarTheme], a widget that overrides the default configuration of a search bar. +/// * [SearchViewTheme], a widget that overrides the default configuration of a search view. +class SearchAnchor extends StatefulWidget { + /// Creates a const [SearchAnchor]. + /// + /// The [builder] and [suggestionsBuilder] arguments are required. + const SearchAnchor({ + super.key, + this.isFullScreen, + this.searchController, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + this.viewConstraints, + required this.builder, + required this.suggestionsBuilder, + }); + + /// Create a [SearchAnchor] that has a [SearchBar] which opens a search view. + /// + /// All the barX parameters are used to customize the anchor. Similarly, all the + /// viewX parameters are used to override the view's defaults. + /// + /// {@tool dartpad} + /// This example shows how to use a [SearchAnchor.bar] which uses a default search + /// bar to open a search view route. + /// + /// ** See code in examples/api/lib/material/search_anchor/search_anchor.0.dart ** + /// {@end-tool} + /// + /// The [suggestionsBuilder] argument must not be null. + factory SearchAnchor.bar( + {Widget? barLeading, + Iterable? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + MaterialStateProperty? barElevation, + MaterialStateProperty? barBackgroundColor, + MaterialStateProperty? barOverlayColor, + MaterialStateProperty? barSide, + MaterialStateProperty? barShape, + MaterialStateProperty? barPadding, + MaterialStateProperty? barTextStyle, + MaterialStateProperty? barHintStyle, + Widget? viewLeading, + Iterable? viewTrailing, + String? viewHintText, + Color? viewBackgroundColor, + double? viewElevation, + BorderSide? viewSide, + OutlinedBorder? viewShape, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + Color? dividerColor, + BoxConstraints? constraints, + bool? isFullScreen, + SearchController searchController, + required SuggestionsBuilder suggestionsBuilder}) = + _SearchAnchorWithSearchBar; + + /// Whether the search view grows to fill the entire screen when the + /// [SearchAnchor] is tapped. + /// + /// By default, the search view is full-screen on mobile devices. On other + /// platforms, the search view only grows to a specific size that is determined + /// by the anchor and the default size. + final bool? isFullScreen; + + /// An optional controller that allows opening and closing of the search view from + /// other widgets. + /// + /// If this is null, one internal search controller is created automatically + /// and it is used to open the search view when the user taps on the anchor. + final SearchController? searchController; + + /// Optional callback to obtain a widget to lay out the suggestion list of the + /// search view. + /// + /// Default view uses a [ListView] with a vertical scroll direction. + final ViewBuilder? viewBuilder; + + /// An optional widget to display before the text input field when the search + /// view is open. + /// + /// Typically the [viewLeading] widget is an [Icon] or an [IconButton]. + /// + /// Defaults to a back button which pops the view. + final Widget? viewLeading; + + /// An optional widget list to display after the text input field when the search + /// view is open. + /// + /// Typically the [viewTrailing] widget list only has one or two widgets. + /// + /// Defaults to an icon button which clears the text in the input field. + final Iterable? viewTrailing; + + /// Text that is displayed when the search bar's input field is empty. + final String? viewHintText; + + /// The search view's background fill color. + /// + /// If null, the value of [SearchViewThemeData.backgroundColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surface]. + final Color? viewBackgroundColor; + + /// The elevation of the search view's [Material]. + /// + /// If null, the value of [SearchViewThemeData.elevation] will be used. If this + /// is also null, then default value is 6.0. + final double? viewElevation; + + /// The surface tint color of the search view's [Material]. + /// + /// See [Material.surfaceTintColor] for more details. + /// + /// If null, the value of [SearchViewThemeData.surfaceTintColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceTint]. + final Color? viewSurfaceTintColor; + + /// The color and weight of the search view's outline. + /// + /// This value is combined with [viewShape] to create a shape decorated + /// with an outline. This will be ignored if the view is full-screen. + /// + /// If null, the value of [SearchViewThemeData.side] will be used. If this is + /// also null, the search view doesn't have a side by default. + final BorderSide? viewSide; + + /// The shape of the search view's underlying [Material]. + /// + /// This shape is combined with [viewSide] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchViewThemeData.shape] will be used. + /// If this is also null, then the default value is a rectangle shape for full-screen + /// mode and a [RoundedRectangleBorder] shape with a 28.0 radius otherwise. + final OutlinedBorder? viewShape; + + /// The style to use for the text being edited on the search view. + /// + /// If null, defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurface]. + final TextStyle? headerTextStyle; + + /// The style to use for the [viewHintText] on the search view. + /// + /// If null, the value of [SearchViewThemeData.headerHintStyle] will be used. + /// If this is also null, the value of [headerTextStyle] will be used. If this is also null, + /// defaults to the `bodyLarge` text style from the current [Theme]. The default + /// text color is [ColorScheme.onSurfaceVariant]. + final TextStyle? headerHintStyle; + + /// The color of the divider on the search view. + /// + /// If this property is null, then [SearchViewThemeData.dividerColor] is used. + /// If that is also null, the default value is [ColorScheme.outline]. + final Color? dividerColor; + + /// Optional size constraints for the search view. + /// + /// If null, the value of [SearchViewThemeData.constraints] will be used. If + /// this is also null, then the constraints defaults to: + /// ```dart + /// const BoxConstraints(minWidth: 360.0, minHeight: 240.0) + /// ``` + final BoxConstraints? viewConstraints; + + /// Called to create a widget which can open a search view route when it is tapped. + /// + /// The widget returned by this builder is faded out when it is tapped. + /// At the same time a search view route is faded in. + /// + /// This must not be null. + final SearchAnchorChildBuilder builder; + + /// Called to get the suggestion list for the search view. + /// + /// By default, the list returned by this builder is laid out in a [ListView]. + /// To get a different layout, use [viewBuilder] to override. + final SuggestionsBuilder suggestionsBuilder; + + @override + State createState() => _SearchAnchorState(); +} + +class _SearchAnchorState extends State { + bool _anchorIsVisible = true; + final GlobalKey _anchorKey = GlobalKey(); + bool get _viewIsOpen => !_anchorIsVisible; + late SearchController? _internalSearchController; + SearchController get _searchController => + widget.searchController ?? _internalSearchController!; + + @override + void initState() { + super.initState(); + if (widget.searchController == null) { + _internalSearchController = SearchController(); + } + _searchController._attach(this); + } + + @override + void dispose() { + super.dispose(); + _searchController._detach(this); + _internalSearchController = null; + } + + void _openView() { + Navigator.of(context).push(_SearchViewRoute( + viewLeading: widget.viewLeading, + viewTrailing: widget.viewTrailing, + viewHintText: widget.viewHintText, + viewBackgroundColor: widget.viewBackgroundColor, + viewElevation: widget.viewElevation, + viewSurfaceTintColor: widget.viewSurfaceTintColor, + viewSide: widget.viewSide, + viewShape: widget.viewShape, + viewHeaderTextStyle: widget.headerTextStyle, + viewHeaderHintStyle: widget.headerHintStyle, + dividerColor: widget.dividerColor, + viewConstraints: widget.viewConstraints, + showFullScreenView: getShowFullScreenView(), + toggleVisibility: toggleVisibility, + textDirection: Directionality.of(context), + viewBuilder: widget.viewBuilder, + anchorKey: _anchorKey, + searchController: _searchController, + suggestionsBuilder: widget.suggestionsBuilder, + )); + } + + void _closeView(String? selectedText) { + if (selectedText != null) { + _searchController.text = selectedText; + } + Navigator.of(context).pop(); + } + + Rect? getRect(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox searchBarBox = context.findRenderObject()! as RenderBox; + final Size boxSize = searchBarBox.size; + final Offset boxLocation = searchBarBox.localToGlobal(Offset.zero); + return boxLocation & boxSize; + } + return null; + } + + bool toggleVisibility() { + setState(() { + _anchorIsVisible = !_anchorIsVisible; + }); + return _anchorIsVisible; + } + + bool getShowFullScreenView() { + if (widget.isFullScreen != null) { + return widget.isFullScreen!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return true; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + key: _anchorKey, + opacity: _anchorIsVisible ? 1.0 : 0.0, + duration: _kAnchorFadeDuration, + child: GestureDetector( + onTap: _openView, + child: widget.builder(context, _searchController), + ), + ); + } +} + +class _SearchViewRoute extends PopupRoute<_SearchViewRoute> { + _SearchViewRoute({ + this.toggleVisibility, + this.textDirection, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + required this.showFullScreenView, + required this.anchorKey, + required this.searchController, + required this.suggestionsBuilder, + }); + + final ValueGetter? toggleVisibility; + final TextDirection? textDirection; + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final bool showFullScreenView; + final GlobalKey anchorKey; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => 'Dismiss'; + + late final SearchViewThemeData viewDefaults; + late final SearchViewThemeData viewTheme; + late final DividerThemeData dividerTheme; + final RectTween _rectTween = RectTween(); + + Rect? getRect() { + final BuildContext? context = anchorKey.currentContext; + if (context != null) { + final RenderBox searchBarBox = context.findRenderObject()! as RenderBox; + final Size boxSize = searchBarBox.size; + final Offset boxLocation = searchBarBox.localToGlobal(Offset.zero); + return boxLocation & boxSize; + } + return null; + } + + @override + TickerFuture didPush() { + assert(anchorKey.currentContext != null); + updateViewConfig(anchorKey.currentContext!); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + return super.didPush(); + } + + @override + bool didPop(_SearchViewRoute? result) { + assert(anchorKey.currentContext != null); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + return super.didPop(result); + } + + void updateViewConfig(BuildContext context) { + viewDefaults = + _SearchViewDefaultsM3(context, isFullScreen: showFullScreenView); + viewTheme = SearchViewTheme.of(context); + dividerTheme = DividerTheme.of(context); + } + + void updateTweens(BuildContext context) { + final Size screenSize = MediaQuery.of(context).size; + final Rect anchorRect = getRect() ?? Rect.zero; + + // Check if the search view goes off the screen. + final BoxConstraints effectiveConstraints = + viewConstraints ?? viewTheme.constraints ?? viewDefaults.constraints!; + final double verticalDistanceToEdge = screenSize.height - anchorRect.top; + final double endHeight = math.max(effectiveConstraints.minHeight, + math.min(screenSize.height * 2 / 3, verticalDistanceToEdge)); + _rectTween.begin = anchorRect; + + switch (textDirection ?? TextDirection.ltr) { + case TextDirection.ltr: + final double viewEdgeToScreenEdge = screenSize.width - anchorRect.left; + final double endWidth = math.max(effectiveConstraints.minWidth, + math.min(anchorRect.width, viewEdgeToScreenEdge)); + final Size endSize = Size(endWidth, endHeight); + _rectTween.end = showFullScreenView + ? Offset.zero & screenSize + : (anchorRect.topLeft & endSize); + return; + case TextDirection.rtl: + final double viewEdgeToScreenEdge = anchorRect.right; + final double endWidth = math.max(effectiveConstraints.minWidth, + math.min(anchorRect.width, viewEdgeToScreenEdge)); + final Offset topLeft = + Offset(math.max(anchorRect.right - endWidth, 0.0), anchorRect.top); + final Size endSize = Size(endWidth, endHeight); + _rectTween.end = + showFullScreenView ? Offset.zero & screenSize : (topLeft & endSize); + } + } + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + return Directionality( + textDirection: textDirection ?? TextDirection.ltr, + child: AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final Animation curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + ); + + final Rect viewRect = _rectTween.evaluate(curvedAnimation)!; + final double topPadding = showFullScreenView + ? lerpDouble(0.0, MediaQuery.of(context).padding.top, + curvedAnimation.value)! + : 0.0; + + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: _kViewFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ), + child: _ViewContent( + viewLeading: viewLeading, + viewTrailing: viewTrailing, + viewHintText: viewHintText, + viewBackgroundColor: viewBackgroundColor, + viewElevation: viewElevation, + viewSurfaceTintColor: viewSurfaceTintColor, + viewSide: viewSide, + viewShape: viewShape, + viewHeaderTextStyle: viewHeaderTextStyle, + viewHeaderHintStyle: viewHeaderHintStyle, + dividerColor: dividerColor, + viewConstraints: viewConstraints, + showFullScreenView: showFullScreenView, + animation: curvedAnimation, + getRect: getRect, + topPadding: topPadding, + viewRect: viewRect, + viewDefaults: viewDefaults, + viewTheme: viewTheme, + dividerTheme: dividerTheme, + viewBuilder: viewBuilder, + searchController: searchController, + suggestionsBuilder: suggestionsBuilder, + ), + ); + }), + ); + } + + @override + Duration get transitionDuration => _kOpenViewDuration; +} + +class _ViewContent extends StatefulWidget { + const _ViewContent({ + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + required this.showFullScreenView, + required this.getRect, + required this.topPadding, + required this.animation, + required this.viewRect, + required this.viewDefaults, + required this.viewTheme, + required this.dividerTheme, + required this.searchController, + required this.suggestionsBuilder, + }); + + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final bool showFullScreenView; + final ValueGetter getRect; + final double topPadding; + final Animation animation; + final Rect viewRect; + final SearchViewThemeData viewDefaults; + final SearchViewThemeData viewTheme; + final DividerThemeData dividerTheme; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + + @override + State<_ViewContent> createState() => _ViewContentState(); +} + +class _ViewContentState extends State<_ViewContent> { + Size? _screenSize; + late Rect _viewRect; + late final SearchController _controller; + late Iterable result; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _viewRect = widget.viewRect; + _controller = widget.searchController; + result = widget.suggestionsBuilder(context, _controller); + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + } + + @override + void didUpdateWidget(covariant _ViewContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.viewRect != oldWidget.viewRect) { + setState(() { + _viewRect = widget.viewRect; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final Size updatedScreenSize = MediaQuery.of(context).size; + if (_screenSize != updatedScreenSize) { + _screenSize = updatedScreenSize; + setState(() { + final Rect anchorRect = widget.getRect() ?? _viewRect; + final BoxConstraints constraints = widget.viewConstraints ?? + widget.viewTheme.constraints ?? + widget.viewDefaults.constraints!; + final Size updatedViewSize = Size( + math.max(constraints.minWidth, anchorRect.width), _viewRect.height); + switch (Directionality.of(context)) { + case TextDirection.ltr: + final Offset updatedPosition = anchorRect.topLeft; + _viewRect = updatedPosition & updatedViewSize; + return; + case TextDirection.rtl: + final Offset topLeft = Offset( + math.max(anchorRect.right - updatedViewSize.width, 0.0), + anchorRect.top); + _viewRect = topLeft & updatedViewSize; + } + }); + } + } + + Widget viewBuilder(Iterable suggestions) { + if (widget.viewBuilder == null) { + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: ListView(children: suggestions.toList()), + ); + } + return widget.viewBuilder!(suggestions); + } + + void updateSuggestions() { + setState(() { + result = widget.suggestionsBuilder(context, _controller); + }); + } + + @override + Widget build(BuildContext context) { + final Widget defaultLeading = IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(); + }, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ); + + final List defaultTrailing = [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _controller.clear(); + updateSuggestions(); + }, + ), + ]; + + final Color effectiveBackgroundColor = widget.viewBackgroundColor ?? + widget.viewTheme.backgroundColor ?? + widget.viewDefaults.backgroundColor!; + final Color effectiveSurfaceTint = widget.viewSurfaceTintColor ?? + widget.viewTheme.surfaceTintColor ?? + widget.viewDefaults.surfaceTintColor!; + final double effectiveElevation = widget.viewElevation ?? + widget.viewTheme.elevation ?? + widget.viewDefaults.elevation!; + final BorderSide? effectiveSide = + widget.viewSide ?? widget.viewTheme.side ?? widget.viewDefaults.side; + OutlinedBorder effectiveShape = widget.viewShape ?? + widget.viewTheme.shape ?? + widget.viewDefaults.shape!; + if (effectiveSide != null) { + effectiveShape = effectiveShape.copyWith(side: effectiveSide); + } + final Color effectiveDividerColor = widget.dividerColor ?? + widget.viewTheme.dividerColor ?? + widget.dividerTheme.color ?? + widget.viewDefaults.dividerColor!; + final TextStyle? effectiveTextStyle = widget.viewHeaderTextStyle ?? + widget.viewTheme.headerTextStyle ?? + widget.viewDefaults.headerTextStyle; + final TextStyle? effectiveHintStyle = widget.viewHeaderHintStyle ?? + widget.viewTheme.headerHintStyle ?? + widget.viewHeaderTextStyle ?? + widget.viewTheme.headerTextStyle ?? + widget.viewDefaults.headerHintStyle; + + final Widget viewDivider = DividerTheme( + data: widget.dividerTheme.copyWith(color: effectiveDividerColor), + child: const Divider(height: 1), + ); + + return Align( + alignment: Alignment.topLeft, + child: Transform.translate( + offset: _viewRect.topLeft, + child: SizedBox( + width: _viewRect.width, + height: _viewRect.height, + child: Material( + shape: effectiveShape, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTint, + elevation: effectiveElevation, + child: FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewIconsFadeOnInterval, + reverseCurve: _kViewIconsFadeOnInterval.flipped, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.only(top: widget.topPadding), + child: SafeArea( + top: false, + bottom: false, + child: SearchBar( + constraints: widget.showFullScreenView + ? BoxConstraints( + minHeight: + _SearchViewDefaultsM3.fullScreenBarHeight) + : null, + focusNode: _focusNode, + leading: widget.viewLeading ?? defaultLeading, + trailing: widget.viewTrailing ?? defaultTrailing, + hintText: widget.viewHintText, + backgroundColor: const MaterialStatePropertyAll( + Colors.transparent), + overlayColor: const MaterialStatePropertyAll( + Colors.transparent), + elevation: const MaterialStatePropertyAll(0.0), + textStyle: MaterialStatePropertyAll( + effectiveTextStyle), + hintStyle: MaterialStatePropertyAll( + effectiveHintStyle), + controller: _controller, + onChanged: (_) { + updateSuggestions(); + }, + ), + ), + ), + FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewDividerFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ), + child: viewDivider), + Expanded( + child: FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewListFadeOnInterval, + reverseCurve: _kViewListFadeOnInterval.flipped, + ), + child: viewBuilder(result), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _SearchAnchorWithSearchBar extends SearchAnchor { + _SearchAnchorWithSearchBar( + {Widget? barLeading, + Iterable? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + MaterialStateProperty? barElevation, + MaterialStateProperty? barBackgroundColor, + MaterialStateProperty? barOverlayColor, + MaterialStateProperty? barSide, + MaterialStateProperty? barShape, + MaterialStateProperty? barPadding, + MaterialStateProperty? barTextStyle, + MaterialStateProperty? barHintStyle, + super.viewLeading, + super.viewTrailing, + String? viewHintText, + super.viewBackgroundColor, + super.viewElevation, + super.viewSide, + super.viewShape, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + super.dividerColor, + BoxConstraints? constraints, + super.isFullScreen, + super.searchController, + required super.suggestionsBuilder}) + : super( + viewHintText: viewHintText ?? barHintText, + headerTextStyle: viewHeaderTextStyle, + headerHintStyle: viewHeaderHintStyle, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + constraints: constraints, + controller: controller, + onTap: () { + controller.openView(); + onTap?.call(); + }, + onChanged: (_) { + controller.openView(); + }, + hintText: barHintText, + hintStyle: barHintStyle, + textStyle: barTextStyle, + elevation: barElevation, + backgroundColor: barBackgroundColor, + overlayColor: barOverlayColor, + side: barSide, + shape: barShape, + padding: barPadding ?? + const MaterialStatePropertyAll( + EdgeInsets.symmetric(horizontal: 16.0)), + leading: barLeading ?? const Icon(Icons.search), + trailing: barTrailing, + ); + }); +} + +/// A controller to manage a search view created by [SearchAnchor]. +/// +/// A [SearchController] is used to control a menu after it has been created, +/// with methods such as [openView] and [closeView]. It can also control the text in the +/// input field. +/// +/// See also: +/// +/// * [SearchAnchor], a widget that defines a region that opens a search view. +/// * [TextEditingController], A controller for an editable text field. +class SearchController extends TextEditingController { + // The anchor that this controller controls. + // + // This is set automatically when a [SearchController] is given to the anchor + // it controls. + _SearchAnchorState? _anchor; + + /// Whether or not the associated search view is currently open. + bool get isOpen { + assert(_anchor != null); + return _anchor!._viewIsOpen; + } + + /// Opens the search view that this controller is associated with. + void openView() { + assert(_anchor != null); + _anchor!._openView(); + } + + /// Close the search view that this search controller is associated with. + /// + /// If `selectedText` is given, then the text value of the controller is set to + /// `selectedText`. + void closeView(String? selectedText) { + assert(_anchor != null); + _anchor!._closeView(selectedText); + } + + // ignore: use_setters_to_change_properties + void _attach(_SearchAnchorState anchor) { + _anchor = anchor; + } + + void _detach(_SearchAnchorState anchor) { + if (_anchor == anchor) { + _anchor = null; + } + } +} + +/// A Material Design search bar. +/// +/// Search bars include a [leading] Search icon, a text input field and optional +/// [trailing] icons. A search bar is typically used to open a search view. +/// It is the default trigger for a search view. +/// +/// For [TextDirection.ltr], the [leading] widget is on the left side of the bar. +/// It should contain either a navigational action (such as a menu or up-arrow) +/// or a non-functional search icon. +/// +/// The [trailing] is an optional list that appears at the other end of +/// the search bar. Typically only one or two action icons are included. +/// These actions can represent additional modes of searching (like voice search), +/// a separate high-level action (such as current location) or an overflow menu. +class SearchBar extends StatefulWidget { + /// Creates a Material Design search bar. + const SearchBar({ + super.key, + this.controller, + this.focusNode, + this.hintText, + this.leading, + this.trailing, + this.onTap, + this.onChanged, + this.constraints, + this.elevation, + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.overlayColor, + this.side, + this.shape, + this.padding, + this.textStyle, + this.hintStyle, + }); + + /// Controls the text being edited in the search bar's text field. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Text that suggests what sort of input the field accepts. + /// + /// Displayed at the same location on the screen where text may be entered + /// when the input is empty. + /// + /// Defaults to null. + final String? hintText; + + /// A widget to display before the text input field. + /// + /// Typically the [leading] widget is an [Icon] or an [IconButton]. + final Widget? leading; + + /// A list of Widgets to display in a row after the text field. + /// + /// Typically these actions can represent additional modes of searching + /// (like voice search), an avatar, a separate high-level action (such as + /// current location) or an overflow menu. There should not be more than + /// two trailing actions. + final Iterable? trailing; + + /// Called when the user taps this search bar. + final GestureTapCallback? onTap; + + /// Invoked upon user input. + final ValueChanged? onChanged; + + /// Optional size constraints for the search bar. + /// + /// If null, the value of [SearchBarThemeData.constraints] will be used. If + /// this is also null, then the constraints defaults to: + /// ```dart + /// const BoxConstraints(minWidth: 360.0, maxWidth: 800.0, minHeight: 56.0) + /// ``` + final BoxConstraints? constraints; + + /// The elevation of the search bar's [Material]. + /// + /// If null, the value of [SearchBarThemeData.elevation] will be used. If this + /// is also null, then default value is 6.0. + final MaterialStateProperty? elevation; + + /// The search bar's background fill color. + /// + /// If null, the value of [SearchBarThemeData.backgroundColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surface]. + final MaterialStateProperty? backgroundColor; + + /// The shadow color of the search bar's [Material]. + /// + /// If null, the value of [SearchBarThemeData.shadowColor] will be used. + /// If this is also null, then the default value is [ColorScheme.shadow]. + final MaterialStateProperty? shadowColor; + + /// The surface tint color of the search bar's [Material]. + /// + /// See [Material.surfaceTintColor] for more details. + /// + /// If null, the value of [SearchBarThemeData.surfaceTintColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceTint]. + final MaterialStateProperty? surfaceTintColor; + + /// The highlight color that's typically used to indicate that + /// the search bar is focused, hovered, or pressed. + final MaterialStateProperty? overlayColor; + + /// The color and weight of the search bar's outline. + /// + /// This value is combined with [shape] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchBarThemeData.side] will be used. If this is + /// also null, the search bar doesn't have a side by default. + final MaterialStateProperty? side; + + /// The shape of the search bar's underlying [Material]. + /// + /// This shape is combined with [side] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchBarThemeData.shape] will be used. + /// If this is also null, defaults to [StadiumBorder]. + final MaterialStateProperty? shape; + + /// The padding between the search bar's boundary and its contents. + /// + /// If null, the value of [SearchBarThemeData.padding] will be used. + /// If this is also null, then the default value is 16.0 horizontally. + final MaterialStateProperty? padding; + + /// The style to use for the text being edited. + /// + /// If null, defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurface]. + final MaterialStateProperty? textStyle; + + /// The style to use for the [hintText]. + /// + /// If null, the value of [SearchBarThemeData.hintStyle] will be used. If this + /// is also null, the value of [textStyle] will be used. If this is also null, + /// defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurfaceVariant]. + final MaterialStateProperty? hintStyle; + + @override + State createState() => _SearchBarState(); +} + +class _SearchBarState extends State { + late final MaterialStatesController _internalStatesController; + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _internalStatesController = MaterialStatesController(); + _internalStatesController.addListener(() { + setState(() {}); + }); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void dispose() { + _internalStatesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final IconThemeData iconTheme = IconTheme.of(context); + final SearchBarThemeData searchBarTheme = SearchBarTheme.of(context); + final SearchBarThemeData defaults = _SearchBarDefaultsM3(context); + + T? resolve( + MaterialStateProperty? widgetValue, + MaterialStateProperty? themeValue, + MaterialStateProperty? defaultValue, + ) { + final Set states = _internalStatesController.value; + return widgetValue?.resolve(states) ?? + themeValue?.resolve(states) ?? + defaultValue?.resolve(states); + } + + final TextStyle? effectiveTextStyle = resolve( + widget.textStyle, searchBarTheme.textStyle, defaults.textStyle); + final double? effectiveElevation = resolve( + widget.elevation, searchBarTheme.elevation, defaults.elevation); + final Color? effectiveShadowColor = resolve( + widget.shadowColor, searchBarTheme.shadowColor, defaults.shadowColor); + final Color? effectiveBackgroundColor = resolve( + widget.backgroundColor, + searchBarTheme.backgroundColor, + defaults.backgroundColor); + final Color? effectiveSurfaceTintColor = resolve( + widget.surfaceTintColor, + searchBarTheme.surfaceTintColor, + defaults.surfaceTintColor); + final OutlinedBorder? effectiveShape = resolve( + widget.shape, searchBarTheme.shape, defaults.shape); + final BorderSide? effectiveSide = + resolve(widget.side, searchBarTheme.side, defaults.side); + final EdgeInsetsGeometry? effectivePadding = resolve( + widget.padding, searchBarTheme.padding, defaults.padding); + final MaterialStateProperty? effectiveOverlayColor = + widget.overlayColor ?? + searchBarTheme.overlayColor ?? + defaults.overlayColor; + + final Set states = _internalStatesController.value; + final TextStyle? effectiveHintStyle = widget.hintStyle?.resolve(states) ?? + searchBarTheme.hintStyle?.resolve(states) ?? + widget.textStyle?.resolve(states) ?? + searchBarTheme.textStyle?.resolve(states) ?? + defaults.hintStyle?.resolve(states); + + final bool isDark = Theme.of(context).brightness == Brightness.dark; + bool isIconThemeColorDefault(Color? color) { + if (isDark) { + return color == kDefaultIconLightColor; + } + return color == kDefaultIconDarkColor; + } + + Widget? leading; + if (widget.leading != null) { + leading = IconTheme.merge( + data: isIconThemeColorDefault(iconTheme.color) + ? IconThemeData(color: colorScheme.onSurface) + : iconTheme, + child: widget.leading!, + ); + } + + List? trailing; + if (widget.trailing != null) { + trailing = widget.trailing + ?.map((Widget trailing) => IconTheme.merge( + data: isIconThemeColorDefault(iconTheme.color) + ? IconThemeData(color: colorScheme.onSurfaceVariant) + : iconTheme, + child: trailing, + )) + .toList(); + } + + return ConstrainedBox( + constraints: widget.constraints ?? + searchBarTheme.constraints ?? + defaults.constraints!, + child: Material( + elevation: effectiveElevation!, + shadowColor: effectiveShadowColor, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTintColor, + shape: effectiveShape?.copyWith(side: effectiveSide), + child: InkWell( + onTap: () { + widget.onTap?.call(); + _focusNode.requestFocus(); + }, + overlayColor: effectiveOverlayColor, + customBorder: effectiveShape?.copyWith(side: effectiveSide), + statesController: _internalStatesController, + child: Padding( + padding: effectivePadding!, + child: Row( + textDirection: textDirection, + children: [ + if (leading != null) leading, + Expanded( + child: IgnorePointer( + child: Padding( + padding: effectivePadding, + child: TextField( + focusNode: _focusNode, + onChanged: widget.onChanged, + controller: widget.controller, + style: effectiveTextStyle, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.hintText, + hintStyle: effectiveHintStyle, + ), + ), + ), + )), + if (trailing != null) ...trailing, + ], + ), + ), + ), + ), + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - SearchBar + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_162 + +class _SearchBarDefaultsM3 extends SearchBarThemeData { + _SearchBarDefaultsM3(this.context); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + MaterialStateProperty? get backgroundColor => + MaterialStatePropertyAll(_colors.surface); + + @override + MaterialStateProperty? get elevation => + const MaterialStatePropertyAll(6.0); + + @override + MaterialStateProperty? get shadowColor => + MaterialStatePropertyAll(_colors.shadow); + + @override + MaterialStateProperty? get surfaceTintColor => + MaterialStatePropertyAll(_colors.surfaceTint); + + @override + MaterialStateProperty? get overlayColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return Colors.transparent; + } + return Colors.transparent; + }); + + // No default side + + @override + MaterialStateProperty? get shape => + const MaterialStatePropertyAll(StadiumBorder()); + + @override + MaterialStateProperty? get padding => + const MaterialStatePropertyAll( + EdgeInsets.symmetric(horizontal: 8.0)); + + @override + MaterialStateProperty get textStyle => + MaterialStatePropertyAll( + _textTheme.bodyLarge?.copyWith(color: _colors.onSurface)); + + @override + MaterialStateProperty get hintStyle => + MaterialStatePropertyAll( + _textTheme.bodyLarge?.copyWith(color: _colors.onSurfaceVariant)); + + @override + BoxConstraints get constraints => + const BoxConstraints(minWidth: 360.0, maxWidth: 800.0, minHeight: 56.0); +} + +// END GENERATED TOKEN PROPERTIES - SearchBar + +// BEGIN GENERATED TOKEN PROPERTIES - SearchView + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_162 + +class _SearchViewDefaultsM3 extends SearchViewThemeData { + _SearchViewDefaultsM3(this.context, {required this.isFullScreen}); + + final BuildContext context; + final bool isFullScreen; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + static double fullScreenBarHeight = 72.0; + + @override + Color? get backgroundColor => _colors.surface; + + @override + double? get elevation => 6.0; + + @override + Color? get surfaceTintColor => _colors.surfaceTint; + + // No default side + + @override + OutlinedBorder? get shape => isFullScreen + ? const RoundedRectangleBorder() + : const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28.0))); + + @override + TextStyle? get headerTextStyle => + _textTheme.bodyLarge?.copyWith(color: _colors.onSurface); + + @override + TextStyle? get headerHintStyle => + _textTheme.bodyLarge?.copyWith(color: _colors.onSurfaceVariant); + + @override + BoxConstraints get constraints => + const BoxConstraints(minWidth: 360.0, minHeight: 240.0); + + @override + Color? get dividerColor => _colors.outline; +} + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Examples can assume: +// late BuildContext context; + +/// Defines default property values for descendant [SearchBar] widgets. +/// +/// Descendant widgets obtain the current [SearchBarThemeData] object using +/// `SearchBarTheme.of(context)`. Instances of [SearchBarThemeData] can be customized +/// with [SearchBarThemeData.copyWith]. +/// +/// Typically a [SearchBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.searchBarTheme]. +/// +/// All [SearchBarThemeData] properties are `null` by default. When null, the +/// [SearchBar] will use the values from [ThemeData] if they exist, otherwise it +/// will provide its own defaults based on the overall [Theme]'s colorScheme. +/// See the individual [SearchBar] properties for details. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +@immutable +class SearchBarThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.searchBarTheme]. + const SearchBarThemeData({ + this.elevation, + this.backgroundColor, + this.shadowColor, + this.surfaceTintColor, + this.overlayColor, + this.side, + this.shape, + this.padding, + this.textStyle, + this.hintStyle, + this.constraints, + }); + + /// Overrides the default value of the [SearchBar.elevation]. + final MaterialStateProperty? elevation; + + /// Overrides the default value of the [SearchBar.backgroundColor]. + final MaterialStateProperty? backgroundColor; + + /// Overrides the default value of the [SearchBar.shadowColor]. + final MaterialStateProperty? shadowColor; + + /// Overrides the default value of the [SearchBar.surfaceTintColor]. + final MaterialStateProperty? surfaceTintColor; + + /// Overrides the default value of the [SearchBar.overlayColor]. + final MaterialStateProperty? overlayColor; + + /// Overrides the default value of the [SearchBar.side]. + final MaterialStateProperty? side; + + /// Overrides the default value of the [SearchBar.shape]. + final MaterialStateProperty? shape; + + /// Overrides the default value for [SearchBar.padding]. + final MaterialStateProperty? padding; + + /// Overrides the default value for [SearchBar.textStyle]. + final MaterialStateProperty? textStyle; + + /// Overrides the default value for [SearchBar.hintStyle]. + final MaterialStateProperty? hintStyle; + + /// Overrides the value of size constraints for [SearchBar]. + final BoxConstraints? constraints; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SearchBarThemeData copyWith({ + MaterialStateProperty? elevation, + MaterialStateProperty? backgroundColor, + MaterialStateProperty? shadowColor, + MaterialStateProperty? surfaceTintColor, + MaterialStateProperty? overlayColor, + MaterialStateProperty? side, + MaterialStateProperty? shape, + MaterialStateProperty? padding, + MaterialStateProperty? textStyle, + MaterialStateProperty? hintStyle, + BoxConstraints? constraints, + }) { + return SearchBarThemeData( + elevation: elevation ?? this.elevation, + backgroundColor: backgroundColor ?? this.backgroundColor, + shadowColor: shadowColor ?? this.shadowColor, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + overlayColor: overlayColor ?? this.overlayColor, + side: side ?? this.side, + shape: shape ?? this.shape, + padding: padding ?? this.padding, + textStyle: textStyle ?? this.textStyle, + hintStyle: hintStyle ?? this.hintStyle, + constraints: constraints ?? this.constraints, + ); + } + + /// Linearly interpolate between two [SearchBarThemeData]s. + /// + /// {@macro dart.ui.shadow.lerp} + static SearchBarThemeData? lerp( + SearchBarThemeData? a, SearchBarThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return SearchBarThemeData( + elevation: MaterialStateProperty.lerp( + a?.elevation, b?.elevation, t, lerpDouble), + backgroundColor: MaterialStateProperty.lerp( + a?.backgroundColor, b?.backgroundColor, t, Color.lerp), + shadowColor: MaterialStateProperty.lerp( + a?.shadowColor, b?.shadowColor, t, Color.lerp), + surfaceTintColor: MaterialStateProperty.lerp( + a?.surfaceTintColor, b?.surfaceTintColor, t, Color.lerp), + overlayColor: MaterialStateProperty.lerp( + a?.overlayColor, b?.overlayColor, t, Color.lerp), + side: _lerpSides(a?.side, b?.side, t), + shape: MaterialStateProperty.lerp( + a?.shape, b?.shape, t, OutlinedBorder.lerp), + padding: MaterialStateProperty.lerp( + a?.padding, b?.padding, t, EdgeInsetsGeometry.lerp), + textStyle: MaterialStateProperty.lerp( + a?.textStyle, b?.textStyle, t, TextStyle.lerp), + hintStyle: MaterialStateProperty.lerp( + a?.hintStyle, b?.hintStyle, t, TextStyle.lerp), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + ); + } + + @override + int get hashCode => Object.hash( + elevation, + backgroundColor, + shadowColor, + surfaceTintColor, + overlayColor, + side, + shape, + padding, + textStyle, + hintStyle, + constraints, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SearchBarThemeData && + other.elevation == elevation && + other.backgroundColor == backgroundColor && + other.shadowColor == shadowColor && + other.surfaceTintColor == surfaceTintColor && + other.overlayColor == overlayColor && + other.side == side && + other.shape == shape && + other.padding == padding && + other.textStyle == textStyle && + other.hintStyle == hintStyle && + other.constraints == constraints; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty>( + 'elevation', elevation, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'backgroundColor', backgroundColor, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'shadowColor', shadowColor, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'surfaceTintColor', surfaceTintColor, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'overlayColor', overlayColor, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'side', side, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'shape', shape, + defaultValue: null)); + properties.add( + DiagnosticsProperty>( + 'padding', padding, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'textStyle', textStyle, + defaultValue: null)); + properties.add(DiagnosticsProperty>( + 'hintStyle', hintStyle, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'constraints', constraints, + defaultValue: null)); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static MaterialStateProperty? _lerpSides( + MaterialStateProperty? a, + MaterialStateProperty? b, + double t) { + if (identical(a, b)) { + return a; + } + return _LerpSides(a, b, t); + } +} + +class _LerpSides implements MaterialStateProperty { + const _LerpSides(this.a, this.b, this.t); + + final MaterialStateProperty? a; + final MaterialStateProperty? b; + final double t; + + @override + BorderSide? resolve(Set states) { + final BorderSide? resolvedA = a?.resolve(states); + final BorderSide? resolvedB = b?.resolve(states); + if (identical(resolvedA, resolvedB)) { + return resolvedA; + } + if (resolvedA == null) { + return BorderSide.lerp( + BorderSide(width: 0, color: resolvedB!.color.withAlpha(0)), + resolvedB, + t); + } + if (resolvedB == null) { + return BorderSide.lerp(resolvedA, + BorderSide(width: 0, color: resolvedA.color.withAlpha(0)), t); + } + return BorderSide.lerp(resolvedA, resolvedB, t); + } +} + +/// Applies a search bar theme to descendant [SearchBar] widgets. +/// +/// Descendant widgets obtain the current theme's [SearchBarTheme] object using +/// [SearchBarTheme.of]. When a widget uses [SearchBarTheme.of], it is automatically +/// rebuilt if the theme later changes. +/// +/// A search bar theme can be specified as part of the overall Material theme using +/// [ThemeData.searchBarTheme]. +/// +/// See also: +/// +/// * [SearchBarThemeData], which describes the actual configuration of a search bar +/// theme. +class SearchBarTheme extends InheritedWidget { + /// Constructs a search bar theme that configures all descendant [SearchBar] widgets. + const SearchBarTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The properties used for all descendant [SearchBar] widgets. + final SearchBarThemeData data; + + /// Returns the configuration [data] from the closest [SearchBarTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.searchBarTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SearchBarThemeData theme = SearchBarTheme.of(context); + /// ``` + static SearchBarThemeData of(BuildContext context) { + final SearchBarTheme? searchBarTheme = + context.dependOnInheritedWidgetOfExactType(); + return searchBarTheme?.data ?? const SearchBarThemeData(); + ; + } + + @override + bool updateShouldNotify(SearchBarTheme oldWidget) => data != oldWidget.data; +} + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Examples can assume: +// late BuildContext context; + +/// Defines the configuration of the search views created by the [SearchAnchor] +/// widget. +/// +/// Descendant widgets obtain the current [SearchViewThemeData] object using +/// `SearchViewTheme.of(context)`. +/// +/// Typically, a [SearchViewThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.searchViewTheme]. Otherwise, [SearchViewTheme] can be used +/// to configure its own widget subtree. +/// +/// All [SearchViewThemeData] properties are `null` by default. If any of these +/// properties are null, the search view will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme for the application. +/// * [SearchBarThemeData], which describes the theme for the search bar itself in a +/// [SearchBar] widget. +/// * [SearchAnchor], which is used to open a search view route. +@immutable +class SearchViewThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.searchViewTheme]. + const SearchViewThemeData({ + this.backgroundColor, + this.elevation, + this.surfaceTintColor, + this.constraints, + this.side, + this.shape, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + }); + + /// Overrides the default value of the [SearchAnchor.viewBackgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of the [SearchAnchor.viewElevation]. + final double? elevation; + + /// Overrides the default value of the [SearchAnchor.viewSurfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of the [SearchAnchor.viewSide]. + final BorderSide? side; + + /// Overrides the default value of the [SearchAnchor.viewShape]. + final OutlinedBorder? shape; + + /// Overrides the default value for [SearchAnchor.headerTextStyle]. + final TextStyle? headerTextStyle; + + /// Overrides the default value for [SearchAnchor.headerHintStyle]. + final TextStyle? headerHintStyle; + + /// Overrides the value of size constraints for [SearchAnchor.viewConstraints]. + final BoxConstraints? constraints; + + /// Overrides the value of the divider color for [SearchAnchor.dividerColor]. + final Color? dividerColor; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SearchViewThemeData copyWith({ + Color? backgroundColor, + double? elevation, + Color? surfaceTintColor, + BorderSide? side, + OutlinedBorder? shape, + TextStyle? headerTextStyle, + TextStyle? headerHintStyle, + BoxConstraints? constraints, + Color? dividerColor, + }) { + return SearchViewThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + side: side ?? this.side, + shape: shape ?? this.shape, + headerTextStyle: headerTextStyle ?? this.headerTextStyle, + headerHintStyle: headerHintStyle ?? this.headerHintStyle, + constraints: constraints ?? this.constraints, + dividerColor: dividerColor ?? this.dividerColor, + ); + } + + /// Linearly interpolate between two [SearchViewThemeData]s. + static SearchViewThemeData? lerp( + SearchViewThemeData? a, SearchViewThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return SearchViewThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + side: _lerpSides(a?.side, b?.side, t), + shape: OutlinedBorder.lerp(a?.shape, b?.shape, t), + headerTextStyle: + TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + headerHintStyle: + TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + dividerColor: Color.lerp(a?.dividerColor, b?.dividerColor, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + elevation, + surfaceTintColor, + side, + shape, + headerTextStyle, + headerHintStyle, + constraints, + dividerColor, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SearchViewThemeData && + other.backgroundColor == backgroundColor && + other.elevation == elevation && + other.surfaceTintColor == surfaceTintColor && + other.side == side && + other.shape == shape && + other.headerTextStyle == headerTextStyle && + other.headerHintStyle == headerHintStyle && + other.constraints == constraints && + other.dividerColor == dividerColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty( + 'backgroundColor', backgroundColor, + defaultValue: null)); + properties.add(DiagnosticsProperty('elevation', elevation, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'surfaceTintColor', surfaceTintColor, + defaultValue: null)); + properties.add( + DiagnosticsProperty('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty('shape', shape, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'headerTextStyle', headerTextStyle, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'headerHintStyle', headerHintStyle, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'constraints', constraints, + defaultValue: null)); + properties.add(DiagnosticsProperty('dividerColor', dividerColor, + defaultValue: null)); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null || b == null) { + return null; + } + if (identical(a, b)) { + return a; + } + return BorderSide.lerp(a, b, t); + } +} + +/// An inherited widget that defines the configuration in this widget's +/// descendants for search view created by the [SearchAnchor] widget. +/// +/// A search view theme can be specified as part of the overall Material theme using +/// [ThemeData.searchViewTheme]. +/// +/// See also: +/// +/// * [SearchViewThemeData], which describes the actual configuration of a search view +/// theme. +class SearchViewTheme extends InheritedWidget { + /// Creates a const theme that controls the configurations for the search view + /// created by the [SearchAnchor] widget. + const SearchViewTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The properties used for all descendant [SearchAnchor] widgets. + final SearchViewThemeData data; + + /// Returns the configuration [data] from the closest [SearchViewTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.searchViewTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SearchViewThemeData theme = SearchViewTheme.of(context); + /// ``` + static SearchViewThemeData of(BuildContext context) { + final SearchViewTheme? searchViewTheme = + context.dependOnInheritedWidgetOfExactType(); + return searchViewTheme?.data ?? const SearchViewThemeData(); + } + + @override + bool updateShouldNotify(SearchViewTheme oldWidget) => data != oldWidget.data; +} diff --git a/lib/core/widgets/offline_banner.dart b/lib/core/widgets/offline_banner.dart index db8cbe5..9ef0994 100644 --- a/lib/core/widgets/offline_banner.dart +++ b/lib/core/widgets/offline_banner.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; -class OfflineBanner extends StatelessWidget with PreferredSizeWidget { +class OfflineBanner extends StatelessWidget implements PreferredSizeWidget { const OfflineBanner({super.key}); @override diff --git a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart b/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart index 8f1a060..70e26db 100644 --- a/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart +++ b/lib/features/document_bulk_action/view/widgets/bulk_edit_label_bottom_sheet.dart @@ -61,9 +61,8 @@ class _BulkEditLabelBottomSheetState initialValue: IdQueryParameter.fromId(widget.initialValue), name: "labelFormField", - labelOptions: widget.availableOptionsSelector(state), - textFieldLabel: widget.formFieldLabel, - formBuilderState: _formKey.currentState, + options: widget.availableOptionsSelector(state), + labelText: widget.formFieldLabel, prefixIcon: widget.formFieldPrefixIcon, ), ), diff --git a/lib/features/document_details/cubit/document_details_cubit.dart b/lib/features/document_details/cubit/document_details_cubit.dart index 3f6c76b..e3e2b65 100644 --- a/lib/features/document_details/cubit/document_details_cubit.dart +++ b/lib/features/document_details/cubit/document_details_cubit.dart @@ -21,7 +21,7 @@ class DocumentDetailsCubit extends Cubit { final DocumentChangedNotifier _notifier; final LocalNotificationService _notificationService; final LabelRepository _labelRepository; - final List _subscriptions = []; + DocumentDetailsCubit( this._api, this._labelRepository, @@ -207,9 +207,7 @@ class DocumentDetailsCubit extends Cubit { @override Future close() async { - for (final element in _subscriptions) { - await element.cancel(); - } + _labelRepository.removeListener(this); _notifier.removeListener(this); await super.close(); } diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index 629b5c8..f33ae62 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -218,32 +218,21 @@ class _DocumentDetailsPageState extends State { Widget _buildEditButton() { return BlocBuilder( builder: (context, state) { - final _filteredSuggestions = - state.suggestions?.documentDifference(state.document); + // final _filteredSuggestions = + // state.suggestions?.documentDifference(state.document); return BlocBuilder( builder: (context, connectivityState) { if (!connectivityState.isConnected) { return const SizedBox.shrink(); } - return b.Badge( - position: b.BadgePosition.topEnd(top: -12, end: -6), - showBadge: _filteredSuggestions?.hasSuggestions ?? false, - child: Tooltip( - message: S.of(context)!.editDocumentTooltip, - preferBelow: false, - verticalOffset: 40, - child: FloatingActionButton( - child: const Icon(Icons.edit), - onPressed: () => _onEdit(state.document), - ), + return Tooltip( + message: S.of(context)!.editDocumentTooltip, + preferBelow: false, + verticalOffset: 40, + child: FloatingActionButton( + child: const Icon(Icons.edit), + onPressed: () => _onEdit(state.document), ), - badgeContent: Text( - '${_filteredSuggestions?.suggestionsCount ?? 0}', - style: const TextStyle( - color: Colors.white, - ), - ), - badgeColor: Colors.red, ); }, ); diff --git a/lib/features/document_edit/cubit/document_edit_cubit.dart b/lib/features/document_edit/cubit/document_edit_cubit.dart index b944684..966302f 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.dart @@ -12,10 +12,8 @@ part 'document_edit_cubit.freezed.dart'; class DocumentEditCubit extends Cubit { final DocumentModel _initialDocument; final PaperlessDocumentsApi _docsApi; - - final DocumentChangedNotifier _notifier; final LabelRepository _labelRepository; - final List _subscriptions = []; + final DocumentChangedNotifier _notifier; DocumentEditCubit( this._labelRepository, @@ -23,19 +21,16 @@ class DocumentEditCubit extends Cubit { this._notifier, { required DocumentModel document, }) : _initialDocument = document, - super( - DocumentEditState( - document: document, - correspondents: _labelRepository.state.correspondents, - documentTypes: _labelRepository.state.documentTypes, - storagePaths: _labelRepository.state.storagePaths, - tags: _labelRepository.state.tags, - ), - ) { + super(DocumentEditState(document: document)) { _notifier.addListener(this, onUpdated: replace); _labelRepository.addListener( this, - onChanged: (labels) => emit(state.copyWith()), + onChanged: (labels) => emit(state.copyWith( + correspondents: labels.correspondents, + documentTypes: labels.documentTypes, + storagePaths: labels.storagePaths, + tags: labels.tags, + )), ); } @@ -68,10 +63,8 @@ class DocumentEditCubit extends Cubit { @override Future close() { - for (final sub in _subscriptions) { - sub.cancel(); - } _notifier.removeListener(this); + _labelRepository.removeListener(this); return super.close(); } } diff --git a/lib/features/document_edit/cubit/document_edit_cubit.freezed.dart b/lib/features/document_edit/cubit/document_edit_cubit.freezed.dart index f4f3a32..862472a 100644 --- a/lib/features/document_edit/cubit/document_edit_cubit.freezed.dart +++ b/lib/features/document_edit/cubit/document_edit_cubit.freezed.dart @@ -150,10 +150,10 @@ class __$$_DocumentEditStateCopyWithImpl<$Res> class _$_DocumentEditState implements _DocumentEditState { const _$_DocumentEditState( {required this.document, - required final Map correspondents, - required final Map documentTypes, - required final Map storagePaths, - required final Map tags}) + final Map correspondents = const {}, + final Map documentTypes = const {}, + final Map storagePaths = const {}, + final Map tags = const {}}) : _correspondents = correspondents, _documentTypes = documentTypes, _storagePaths = storagePaths, @@ -163,6 +163,7 @@ class _$_DocumentEditState implements _DocumentEditState { final DocumentModel document; final Map _correspondents; @override + @JsonKey() Map get correspondents { if (_correspondents is EqualUnmodifiableMapView) return _correspondents; // ignore: implicit_dynamic_type @@ -171,6 +172,7 @@ class _$_DocumentEditState implements _DocumentEditState { final Map _documentTypes; @override + @JsonKey() Map get documentTypes { if (_documentTypes is EqualUnmodifiableMapView) return _documentTypes; // ignore: implicit_dynamic_type @@ -179,6 +181,7 @@ class _$_DocumentEditState implements _DocumentEditState { final Map _storagePaths; @override + @JsonKey() Map get storagePaths { if (_storagePaths is EqualUnmodifiableMapView) return _storagePaths; // ignore: implicit_dynamic_type @@ -187,6 +190,7 @@ class _$_DocumentEditState implements _DocumentEditState { final Map _tags; @override + @JsonKey() Map get tags { if (_tags is EqualUnmodifiableMapView) return _tags; // ignore: implicit_dynamic_type @@ -234,10 +238,10 @@ class _$_DocumentEditState implements _DocumentEditState { abstract class _DocumentEditState implements DocumentEditState { const factory _DocumentEditState( {required final DocumentModel document, - required final Map correspondents, - required final Map documentTypes, - required final Map storagePaths, - required final Map tags}) = _$_DocumentEditState; + final Map correspondents, + final Map documentTypes, + final Map storagePaths, + final Map tags}) = _$_DocumentEditState; @override DocumentModel get document; diff --git a/lib/features/document_edit/cubit/document_edit_state.dart b/lib/features/document_edit/cubit/document_edit_state.dart index 04f61cb..6504095 100644 --- a/lib/features/document_edit/cubit/document_edit_state.dart +++ b/lib/features/document_edit/cubit/document_edit_state.dart @@ -4,9 +4,9 @@ part of 'document_edit_cubit.dart'; class DocumentEditState with _$DocumentEditState { const factory DocumentEditState({ required DocumentModel document, - required Map correspondents, - required Map documentTypes, - required Map storagePaths, - required Map tags, + @Default({}) Map correspondents, + @Default({}) Map documentTypes, + @Default({}) Map storagePaths, + @Default({}) Map tags, }) = _DocumentEditState; } diff --git a/lib/features/document_edit/view/document_edit_page.dart b/lib/features/document_edit/view/document_edit_page.dart index c6fed2a..b5e1054 100644 --- a/lib/features/document_edit/view/document_edit_page.dart +++ b/lib/features/document_edit/view/document_edit_page.dart @@ -1,10 +1,11 @@ import 'dart:async'; +import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; -import 'package:form_builder_validators/form_builder_validators.dart'; + import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; @@ -56,6 +57,7 @@ class _DocumentEditPageState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + log("Updated state. correspondents have ${state.correspondents.length} items."); return DefaultTabController( length: 2, child: Scaffold( @@ -95,18 +97,116 @@ class _DocumentEditPageState extends State { _buildTitleFormField(state.document.title).padded(), _buildCreatedAtFormField(state.document.created) .padded(), - _buildCorrespondentFormField( - state.document.correspondent, - state.correspondents, + // Correspondent form field + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddCorrespondentPage( + initialName: initialValue, + ), + ), + addLabelText: S.of(context)!.addCorrespondent, + labelText: S.of(context)!.correspondent, + options: context + .watch() + .state + .correspondents, + initialValue: IdQueryParameter.fromId( + state.document.correspondent, + ), + name: fkCorrespondent, + prefixIcon: const Icon(Icons.person_outlined), + ), + if (_filteredSuggestions + ?.hasSuggestedCorrespondents ?? + false) + _buildSuggestionsSkeleton( + suggestions: + _filteredSuggestions!.correspondents, + itemBuilder: (context, itemData) => + ActionChip( + label: Text( + state.correspondents[itemData]!.name), + onPressed: () { + _formKey + .currentState?.fields[fkCorrespondent] + ?.didChange( + IdQueryParameter.fromId(itemData), + ); + }, + ), + ), + ], ).padded(), - _buildDocumentTypeFormField( - state.document.documentType, - state.documentTypes, + // DocumentType form field + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (currentInput) => + RepositoryProvider.value( + value: context.read(), + child: AddDocumentTypePage( + initialName: currentInput, + ), + ), + addLabelText: S.of(context)!.addDocumentType, + labelText: S.of(context)!.documentType, + initialValue: IdQueryParameter.fromId( + state.document.documentType), + options: state.documentTypes, + name: _DocumentEditPageState.fkDocumentType, + prefixIcon: + const Icon(Icons.description_outlined), + ), + if (_filteredSuggestions + ?.hasSuggestedDocumentTypes ?? + false) + _buildSuggestionsSkeleton( + suggestions: + _filteredSuggestions!.documentTypes, + itemBuilder: (context, itemData) => + ActionChip( + label: Text( + state.documentTypes[itemData]!.name), + onPressed: () => _formKey + .currentState?.fields[fkDocumentType] + ?.didChange( + IdQueryParameter.fromId(itemData), + ), + ), + ), + ], ).padded(), - _buildStoragePathFormField( - state.document.storagePath, - state.storagePaths, + // StoragePath form field + Column( + children: [ + LabelFormField( + showAnyAssignedOption: false, + showNotAssignedOption: false, + addLabelPageBuilder: (initialValue) => + RepositoryProvider.value( + value: context.read(), + child: AddStoragePathPage( + initalName: initialValue), + ), + addLabelText: S.of(context)!.addStoragePath, + labelText: S.of(context)!.storagePath, + options: state.storagePaths, + initialValue: IdQueryParameter.fromId( + state.document.storagePath), + name: fkStoragePath, + prefixIcon: const Icon(Icons.folder_outlined), + ), + ], ).padded(), + // Tag form field TagFormField( initialValue: IdsTagsQuery.included( state.document.tags.toList()), @@ -187,96 +287,6 @@ class _DocumentEditPageState extends State { ); } - Widget _buildStoragePathFormField( - int? initialId, - Map options, - ) { - return Column( - children: [ - LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddStoragePathPage(initalName: initialValue), - ), - textFieldLabel: S.of(context)!.storagePath, - labelOptions: options, - initialValue: IdQueryParameter.fromId(initialId), - name: fkStoragePath, - prefixIcon: const Icon(Icons.folder_outlined), - ), - ], - ); - } - - Widget _buildCorrespondentFormField( - int? initialId, Map options) { - return Column( - children: [ - LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (initialValue) => - RepositoryProvider.value( - value: context.read(), - child: AddCorrespondentPage(initialName: initialValue), - ), - textFieldLabel: S.of(context)!.correspondent, - labelOptions: options, - initialValue: IdQueryParameter.fromId(initialId), - name: fkCorrespondent, - prefixIcon: const Icon(Icons.person_outlined), - ), - if (_filteredSuggestions?.hasSuggestedCorrespondents ?? false) - _buildSuggestionsSkeleton( - suggestions: _filteredSuggestions!.correspondents, - itemBuilder: (context, itemData) => ActionChip( - label: Text(options[itemData]!.name), - onPressed: () => _formKey.currentState?.fields[fkCorrespondent] - ?.didChange((IdQueryParameter.fromId(itemData))), - ), - ), - ], - ); - } - - Widget _buildDocumentTypeFormField( - int? initialId, - Map options, - ) { - return Column( - children: [ - LabelFormField( - notAssignedSelectable: false, - formBuilderState: _formKey.currentState, - labelCreationWidgetBuilder: (currentInput) => - RepositoryProvider.value( - value: context.read(), - child: AddDocumentTypePage( - initialName: currentInput, - ), - ), - textFieldLabel: S.of(context)!.documentType, - initialValue: IdQueryParameter.fromId(initialId), - labelOptions: options, - name: fkDocumentType, - prefixIcon: const Icon(Icons.description_outlined), - ), - if (_filteredSuggestions?.hasSuggestedDocumentTypes ?? false) - _buildSuggestionsSkeleton( - suggestions: _filteredSuggestions!.documentTypes, - itemBuilder: (context, itemData) => ActionChip( - label: Text(options[itemData]!.name), - onPressed: () => _formKey.currentState?.fields[fkDocumentType] - ?.didChange(IdQueryParameter.fromId(itemData)), - ), - ), - ], - ); - } - Future _onSubmit(DocumentModel document) async { if (_formKey.currentState?.saveAndValidate() ?? false) { final values = _formKey.currentState!.value; @@ -308,7 +318,12 @@ class _DocumentEditPageState extends State { Widget _buildTitleFormField(String? initialTitle) { return FormBuilderTextField( name: fkTitle, - validator: FormBuilderValidators.required(), + validator: (value) { + if (value?.trim().isEmpty ?? true) { + return S.of(context)!.thisFieldIsRequired; + } + return null; + }, decoration: InputDecoration( label: Text(S.of(context)!.title), ), @@ -374,3 +389,56 @@ class _DocumentEditPageState extends State { ).padded(); } } + +// class SampleWidget extends StatefulWidget { +// const SampleWidget({super.key}); + +// @override +// State createState() => _SampleWidgetState(); +// } + +// class _SampleWidgetState extends State { +// @override +// Widget build(BuildContext context) { +// return BlocBuilder( +// builder: (context, state) { +// return OptionsFormField( +// options: state.options, +// onAddOption: (option) { +// // This will call the repository and will cause a new state containing the new option to be emitted. +// context.read().addOption(option); +// }, +// ); +// }, +// ); +// } +// } + +// class OptionsFormField extends StatefulWidget { +// final List