From 8ef81ddb33ce104d5d788edff428a1641f50bdd9 Mon Sep 17 00:00:00 2001 From: toly <1981462002@qq.com> Date: Mon, 30 Oct 2023 16:12:23 +0800 Subject: [PATCH] v7 --- .../toly_ui/button/hover_icon_button.dart | 37 +- lib/components/toly_ui/popable/pop_menu.dart | 117 ++ lib/components/toly_ui/popable/popover.dart | 52 + .../toly_ui/popable/toly_pop_menu.dart | 1433 +++++++++++++++++ lib/components/toly_ui/toly_ui.dart | 4 +- .../context_menu_controller.0.dart | 183 +++ .../editable_text_toolbar_builder.0.dart | 89 + .../editable_text_toolbar_builder.1.dart | 109 ++ .../editable_text_toolbar_builder.2.dart | 132 ++ .../selectable_region_toolbar_builder.0.dart | 93 ++ lib/main.dart | 2 +- .../router/app_router_delegate.dart | 194 ++- lib/v6/app/navigation/router/iroute.dart | 120 +- .../app/navigation/router/route_history.dart | 6 + .../app/navigation/views/app_navigation.dart | 2 +- .../navigation/views/app_navigation_rail.dart | 6 +- .../views/app_top_bar/app_router_editor.dart | 64 + .../views/app_top_bar/app_top_bar.dart | 110 ++ .../views/app_top_bar/history_view_icon.dart | 156 ++ .../app_top_bar/route_history_button.dart | 58 + lib/v6/pages/color/color_page.dart | 5 +- lib/v6_result_/app.dart | 1 + .../router/app_router_delegate.dart | 200 +++ .../app/navigation/router/iroute.dart | 185 +++ .../app/navigation/router/route_history.dart | 6 + .../transition/fade_transition_page.dart | 53 + .../transition/no_transition_page.dart | 47 + .../app/navigation/views/app_navigation.dart | 34 + .../navigation/views/app_navigation_rail.dart | 65 + .../navigation/views/app_router_editor.dart | 0 .../app/navigation/views/app_top_bar.dart | 0 lib/v6_result_/app/unit_app.dart | 30 + .../pages/color/color_add_page.dart | 99 ++ .../pages/color/color_detail_page.dart | 25 + lib/v6_result_/pages/color/color_page.dart | 52 + .../pages/counter/counter_page.dart | 43 + lib/v6_result_/pages/empty/empty_page.dart | 30 + .../pages/settings/settings_page.dart | 11 + lib/v6_result_/pages/sort/sort_page.dart | 859 ++++++++++ lib/v6_result_/pages/user/user_page.dart | 11 + lib/v7/app.dart | 1 + .../router/app_router_delegate.dart | 218 +++ lib/v7/app/navigation/router/iroute.dart | 185 +++ .../app/navigation/router/route_history.dart | 6 + .../router/route_history_manager.dart | 12 + .../transition/fade_transition_page.dart | 53 + .../transition/no_transition_page.dart | 47 + .../app/navigation/views/app_navigation.dart | 34 + .../navigation/views/app_navigation_rail.dart | 65 + .../views/app_top_bar/app_router_editor.dart | 64 + .../views/app_top_bar/app_top_bar.dart | 110 ++ .../views/app_top_bar/history_view_icon.dart | 156 ++ .../app_top_bar/route_history_button.dart | 58 + lib/v7/app/unit_app.dart | 30 + lib/v7/pages/color/color_add_page.dart | 99 ++ lib/v7/pages/color/color_detail_page.dart | 25 + lib/v7/pages/color/color_page.dart | 53 + lib/v7/pages/counter/counter_page.dart | 43 + lib/v7/pages/empty/empty_page.dart | 30 + lib/v7/pages/settings/settings_page.dart | 11 + lib/v7/pages/sort/sort_page.dart | 859 ++++++++++ lib/v7/pages/user/user_page.dart | 11 + test/tree/main.dart | 63 + test/tree/node.dart | 71 + 64 files changed, 6856 insertions(+), 171 deletions(-) create mode 100644 lib/components/toly_ui/popable/pop_menu.dart create mode 100644 lib/components/toly_ui/popable/popover.dart create mode 100644 lib/components/toly_ui/popable/toly_pop_menu.dart create mode 100644 lib/go_router/overlay/context_menu/context_menu_controller.0.dart create mode 100644 lib/go_router/overlay/context_menu/editable_text_toolbar_builder.0.dart create mode 100644 lib/go_router/overlay/context_menu/editable_text_toolbar_builder.1.dart create mode 100644 lib/go_router/overlay/context_menu/editable_text_toolbar_builder.2.dart create mode 100644 lib/go_router/overlay/context_menu/selectable_region_toolbar_builder.0.dart create mode 100644 lib/v6/app/navigation/router/route_history.dart create mode 100644 lib/v6/app/navigation/views/app_top_bar/app_router_editor.dart create mode 100644 lib/v6/app/navigation/views/app_top_bar/app_top_bar.dart create mode 100644 lib/v6/app/navigation/views/app_top_bar/history_view_icon.dart create mode 100644 lib/v6/app/navigation/views/app_top_bar/route_history_button.dart create mode 100644 lib/v6_result_/app.dart create mode 100644 lib/v6_result_/app/navigation/router/app_router_delegate.dart create mode 100644 lib/v6_result_/app/navigation/router/iroute.dart create mode 100644 lib/v6_result_/app/navigation/router/route_history.dart create mode 100644 lib/v6_result_/app/navigation/transition/fade_transition_page.dart create mode 100644 lib/v6_result_/app/navigation/transition/no_transition_page.dart create mode 100644 lib/v6_result_/app/navigation/views/app_navigation.dart create mode 100644 lib/v6_result_/app/navigation/views/app_navigation_rail.dart rename lib/{v6 => v6_result_}/app/navigation/views/app_router_editor.dart (100%) rename lib/{v6 => v6_result_}/app/navigation/views/app_top_bar.dart (100%) create mode 100644 lib/v6_result_/app/unit_app.dart create mode 100644 lib/v6_result_/pages/color/color_add_page.dart create mode 100644 lib/v6_result_/pages/color/color_detail_page.dart create mode 100644 lib/v6_result_/pages/color/color_page.dart create mode 100644 lib/v6_result_/pages/counter/counter_page.dart create mode 100644 lib/v6_result_/pages/empty/empty_page.dart create mode 100644 lib/v6_result_/pages/settings/settings_page.dart create mode 100644 lib/v6_result_/pages/sort/sort_page.dart create mode 100644 lib/v6_result_/pages/user/user_page.dart create mode 100644 lib/v7/app.dart create mode 100644 lib/v7/app/navigation/router/app_router_delegate.dart create mode 100644 lib/v7/app/navigation/router/iroute.dart create mode 100644 lib/v7/app/navigation/router/route_history.dart create mode 100644 lib/v7/app/navigation/router/route_history_manager.dart create mode 100644 lib/v7/app/navigation/transition/fade_transition_page.dart create mode 100644 lib/v7/app/navigation/transition/no_transition_page.dart create mode 100644 lib/v7/app/navigation/views/app_navigation.dart create mode 100644 lib/v7/app/navigation/views/app_navigation_rail.dart create mode 100644 lib/v7/app/navigation/views/app_top_bar/app_router_editor.dart create mode 100644 lib/v7/app/navigation/views/app_top_bar/app_top_bar.dart create mode 100644 lib/v7/app/navigation/views/app_top_bar/history_view_icon.dart create mode 100644 lib/v7/app/navigation/views/app_top_bar/route_history_button.dart create mode 100644 lib/v7/app/unit_app.dart create mode 100644 lib/v7/pages/color/color_add_page.dart create mode 100644 lib/v7/pages/color/color_detail_page.dart create mode 100644 lib/v7/pages/color/color_page.dart create mode 100644 lib/v7/pages/counter/counter_page.dart create mode 100644 lib/v7/pages/empty/empty_page.dart create mode 100644 lib/v7/pages/settings/settings_page.dart create mode 100644 lib/v7/pages/sort/sort_page.dart create mode 100644 lib/v7/pages/user/user_page.dart create mode 100644 test/tree/main.dart create mode 100644 test/tree/node.dart diff --git a/lib/components/toly_ui/button/hover_icon_button.dart b/lib/components/toly_ui/button/hover_icon_button.dart index fa718d3..6db116e 100644 --- a/lib/components/toly_ui/button/hover_icon_button.dart +++ b/lib/components/toly_ui/button/hover_icon_button.dart @@ -2,32 +2,47 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class HoverIconButton extends StatefulWidget { - final VoidCallback onPressed; - final IconData icon; - final double size; - final Color? hoverColor; - final Color? defaultColor; - const HoverIconButton({super.key,required this.onPressed ,required this.icon,this.hoverColor,this.size=24,this.defaultColor}); + final VoidCallback? onPressed; + final IconData icon; + final double size; + final Color? hoverColor; + final Color? defaultColor; + final MouseCursor cursor; + + const HoverIconButton({ + super.key, + required this.onPressed, + required this.icon, + this.hoverColor, + this.size = 24, + this.defaultColor, + this.cursor = SystemMouseCursors.cell, + }); @override State createState() => _HoverIconButtonState(); } class _HoverIconButtonState extends State { - bool _hover = false; @override Widget build(BuildContext context) { - Color? color = (_hover)?widget.hoverColor??Theme.of(context).primaryColor:(widget.defaultColor??null); + Color? color = (_hover) + ? widget.hoverColor ?? Theme.of(context).primaryColor + : (widget.defaultColor ?? null); return MouseRegion( - cursor: SystemMouseCursors.click, + cursor: widget.cursor, onEnter: _onEnter, onExit: _onExit, child: GestureDetector( onTap: widget.onPressed, - child: Icon(widget.icon,size: widget.size,color: color,)), + child: Icon( + widget.icon, + size: widget.size, + color: color, + )), ); } @@ -42,4 +57,4 @@ class _HoverIconButtonState extends State { _hover = false; }); } -} \ No newline at end of file +} diff --git a/lib/components/toly_ui/popable/pop_menu.dart b/lib/components/toly_ui/popable/pop_menu.dart new file mode 100644 index 0000000..9f79aae --- /dev/null +++ b/lib/components/toly_ui/popable/pop_menu.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import 'toly_pop_menu.dart'; + +class PopPanel extends StatefulWidget { + final Widget child; + final Size size; + final Offset offset; + + /// Builds the context menu. + final Widget panel; + + const PopPanel({ + super.key, + required this.child, + required this.panel, + this.offset = Offset.zero, + this.size = const Size(250, 0), + }); + + @override + State createState() => _PopPanelState(); +} + +class _PopPanelState extends State> { + final ContextMenuController _contextMenuController = ContextMenuController(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: widget.child, + ); + } + + void _show() { + TolyPopupMenuEntry item = CustomTolyMenuItem( + widget.panel, + size: widget.size, + ); + + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = + Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final Offset offset = Offset(button.size.width / 2, button.size.height)+widget.offset; + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, + ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + showTolyMenu( + elevation: 0, + color: Colors.transparent, + context: context, + items: [item], + position: position, + ).then((T? newValue) { + if (!mounted) { + return null; + } + if (newValue == null) { + // widget.onCanceled?.call(); + return null; + } + // widget.onSelected?.call(newValue); + }); + } + + void _onTap() { + _show(); + } +} + +class CustomTolyMenuItem extends TolyPopupMenuEntry { + final Size size; + final Widget content; + + const CustomTolyMenuItem(this.content, {super.key, required this.size}); + + @override + State createState() => _CustomTolyMenuItemState(); + + @override + // TODO: implement height + double get height => kMinInteractiveDimension; + + @override + bool represents(value) => true; +} + +class _CustomTolyMenuItemState extends State { + + @override + void didUpdateWidget(covariant CustomTolyMenuItem oldWidget) { + print('============_CustomTolyMenuItemState#didUpdateWidget'); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Container( + width: widget.size.width, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 6) + ]), + // padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: widget.content, + // color: Colors.lightBlueAccent, + ); + } +} diff --git a/lib/components/toly_ui/popable/popover.dart b/lib/components/toly_ui/popable/popover.dart new file mode 100644 index 0000000..8424864 --- /dev/null +++ b/lib/components/toly_ui/popable/popover.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +/// A builder that includes an Offset to draw the context menu at. +typedef ContextMenuBuilder = Widget Function(BuildContext context, Offset offset); + + +class Popover extends StatefulWidget { + final Widget child; + /// Builds the context menu. + final ContextMenuBuilder contextMenuBuilder; + + const Popover({ + super.key, + required this.child, + required this.contextMenuBuilder, + }); + + @override + State createState() => _PopoverState(); +} + +class _PopoverState extends State { + + final ContextMenuController _contextMenuController = ContextMenuController(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: widget.child, + ); + } + + void _show(Offset position) { + _contextMenuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return widget.contextMenuBuilder(context, position); + }, + ); + } + + final double width = 200; + + void _onTap() { + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final Offset offset = Offset(button.size.width / 2, button.size.height); + Offset boxOffset = button.localToGlobal(offset, ancestor: overlay); + _show(boxOffset.translate(-width, 0)); + } +} diff --git a/lib/components/toly_ui/popable/toly_pop_menu.dart b/lib/components/toly_ui/popable/toly_pop_menu.dart new file mode 100644 index 0000000..df908d3 --- /dev/null +++ b/lib/components/toly_ui/popable/toly_pop_menu.dart @@ -0,0 +1,1433 @@ +// 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. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +// Examples can assume: +// enum Commands { heroAndScholar, hurricaneCame } +// late bool _heroAndScholar; +// late dynamic _selection; +// late BuildContext context; +// void setState(VoidCallback fn) { } +// enum Menu { itemOne, itemTwo, itemThree, itemFour } + +const Duration _kMenuDuration = Duration(milliseconds: 300); +const double _kMenuCloseIntervalEnd = 2.0 / 3.0; +const double _kMenuHorizontalPadding = 16.0; +const double _kMenuDividerHeight = 16.0; +const double _kMenuMaxWidth = 5.0 * _kMenuWidthStep; +const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; +const double _kMenuVerticalPadding = 8.0; +const double _kMenuWidthStep = 56.0; +const double _kMenuScreenPadding = 8.0; + +/// A base class for entries in a Material Design popup menu. +/// +/// The popup menu widget uses this interface to interact with the menu items. +/// To show a popup menu, use the [showTolyMenu] function. To create a button that +/// shows a popup menu, consider using [TolyPopupMenuButton]. +/// +/// The type `T` is the type of the value(s) the entry represents. All the +/// entries in a given menu must represent values with consistent types. +/// +/// A [TolyPopupMenuEntry] may represent multiple values, for example a row with +/// several icons, or a single entry, for example a menu item with an icon (see +/// [TolyPopupMenuItem]), or no value at all (for example, [TolyPopupMenuDivider]). +/// +/// See also: +/// +/// * [TolyPopupMenuItem], a popup menu entry for a single value. +/// * [TolyPopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [TolyCheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showTolyMenu], a method to dynamically show a popup menu at a given location. +/// * [TolyPopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +abstract class TolyPopupMenuEntry extends StatefulWidget { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const TolyPopupMenuEntry({ super.key }); + + /// The amount of vertical space occupied by this entry. + /// + /// This value is used at the time the [showTolyMenu] method is called, if the + /// `initialValue` argument is provided, to determine the position of this + /// entry when aligning the selected entry over the given `position`. It is + /// otherwise ignored. + double get height; + + /// Whether this entry represents a particular value. + /// + /// This method is used by [showTolyMenu], when it is called, to align the entry + /// representing the `initialValue`, if any, to the given `position`, and then + /// later is called on each entry to determine if it should be highlighted (if + /// the method returns true, the entry will have its background color set to + /// the ambient [ThemeData.highlightColor]). If `initialValue` is null, then + /// this method is not called. + /// + /// If the [TolyPopupMenuEntry] represents a single value, this should return true + /// if the argument matches that value. If it represents multiple values, it + /// should return true if the argument matches any of them. + bool represents(T? value); +} + +/// A horizontal divider in a Material Design popup menu. +/// +/// This widget adapts the [Divider] for use in popup menus. +/// +/// See also: +/// +/// * [TolyPopupMenuItem], for the kinds of items that this widget divides. +/// * [showTolyMenu], a method to dynamically show a popup menu at a given location. +/// * [TolyPopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class TolyPopupMenuDivider extends TolyPopupMenuEntry { + /// Creates a horizontal divider for a popup menu. + /// + /// By default, the divider has a height of 16 logical pixels. + const TolyPopupMenuDivider({ super.key, this.height = _kMenuDividerHeight }); + + /// The height of the divider entry. + /// + /// Defaults to 16 pixels. + @override + final double height; + + @override + bool represents(void value) => false; + + @override + State createState() => _PopupMenuDividerState(); +} + +class _PopupMenuDividerState extends State { + @override + Widget build(BuildContext context) { + double px1 = 1/View.of(context).devicePixelRatio; + // return Divider(height: widget.height,thickness: px1,); + final DividerThemeData? dividerTheme = DividerTheme.of(context); + + + return SizedBox( + height: 16, + child: Center( + child: ColoredBox ( + color: dividerTheme?.color??Color(0xffDEDEDF), + child: Container( + height: px1, + margin: EdgeInsetsDirectional.only(start: 0, end: 0), + ), + ), + ), + ); + } +} + +// This widget only exists to enable _PopupMenuRoute to save the sizes of +// each menu item. The sizes are used by _PopupMenuRouteLayout to compute the +// y coordinate of the menu's origin so that the center of selected menu +// item lines up with the center of its PopupMenuButton. +class _MenuItem extends SingleChildRenderObjectWidget { + const _MenuItem({ + required this.onLayout, + required super.child, + }); + + final ValueChanged onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderMenuItem(onLayout); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderMenuItem renderObject) { + renderObject.onLayout = onLayout; + } +} + +class _RenderMenuItem extends RenderShiftedBox { + _RenderMenuItem(this.onLayout, [RenderBox? child]) : super(child); + + ValueChanged onLayout; + + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child == null) { + return Size.zero; + } + return child!.getDryLayout(constraints); + } + + @override + void performLayout() { + if (child == null) { + size = Size.zero; + } else { + child!.layout(constraints, parentUsesSize: true); + size = constraints.constrain(child!.size); + final BoxParentData childParentData = child!.parentData! as BoxParentData; + childParentData.offset = Offset.zero; + } + onLayout(size); + } +} + +/// An item in a Material Design popup menu. +/// +/// To show a popup menu, use the [showTolyMenu] function. To create a button that +/// shows a popup menu, consider using [TolyPopupMenuButton]. +/// +/// To show a checkmark next to a popup menu item, consider using +/// [TolyCheckedPopupMenuItem]. +/// +/// Typically the [child] of a [TolyPopupMenuItem] is a [Text] widget. More +/// elaborate menus with icons can use a [ListTile]. By default, a +/// [TolyPopupMenuItem] is [kMinInteractiveDimension] pixels high. If you use a widget +/// with a different height, it must be specified in the [height] property. +/// +/// {@tool snippet} +/// +/// Here, a [Text] widget is used with a popup menu item. The `Menu` type +/// is an enum, not shown here. +/// +/// ```dart +/// const PopupMenuItem( +/// value: Menu.itemOne, +/// child: Text('Item 1'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See the example at [TolyPopupMenuButton] for how this example could be used in a +/// complete menu, and see the example at [TolyCheckedPopupMenuItem] for one way to +/// keep the text of [TolyPopupMenuItem]s that use [Text] widgets in their [child] +/// slot aligned with the text of [TolyCheckedPopupMenuItem]s or of [TolyPopupMenuItem] +/// that use a [ListTile] in their [child] slot. +/// +/// See also: +/// +/// * [TolyPopupMenuDivider], which can be used to divide items from each other. +/// * [TolyCheckedPopupMenuItem], a variant of [TolyPopupMenuItem] with a checkmark. +/// * [showTolyMenu], a method to dynamically show a popup menu at a given location. +/// * [TolyPopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class TolyPopupMenuItem extends TolyPopupMenuEntry { + /// Creates an item for a popup menu. + /// + /// By default, the item is [enabled]. + /// + /// The `enabled` and `height` arguments must not be null. + const TolyPopupMenuItem({ + super.key, + this.value, + this.onTap, + this.enabled = true, + this.height = kMinInteractiveDimension, + this.padding, + this.textStyle, + this.labelTextStyle, + this.mouseCursor, + required this.child, + }); + + /// The value that will be returned by [showTolyMenu] if this entry is selected. + final T? value; + + /// Called when the menu item is tapped. + final VoidCallback? onTap; + + /// Whether the user is permitted to select this item. + /// + /// Defaults to true. If this is false, then the item will not react to + /// touches. + final bool enabled; + + /// The minimum height of the menu item. + /// + /// Defaults to [kMinInteractiveDimension] pixels. + @override + final double height; + + /// The padding of the menu item. + /// + /// The [height] property may interact with the applied padding. For example, + /// If a [height] greater than the height of the sum of the padding and [child] + /// is provided, then the padding's effect will not be visible. + /// + /// When null, the horizontal padding defaults to 16.0 on both sides. + final EdgeInsets? padding; + + /// The text style of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.textStyle] is used. + /// If [PopupMenuThemeData.textStyle] is also null, then [TextTheme.titleMedium] + /// of [ThemeData.textTheme] is used. + final TextStyle? textStyle; + + /// The label style of the popup menu item. + /// + /// When [ThemeData.useMaterial3] is true, this styles the text of the popup menu item. + /// + /// If this property is null, then [PopupMenuThemeData.labelTextStyle] is used. + /// If [PopupMenuThemeData.labelTextStyle] is also null, then [TextTheme.labelLarge] + /// is used with the [ColorScheme.onSurface] color when popup menu item is enabled and + /// the [ColorScheme.onSurface] color with 0.38 opacity when the popup menu item is disabled. + final MaterialStateProperty? labelTextStyle; + + /// {@template flutter.material.popupmenu.mouseCursor} + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [MaterialStateProperty], + /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: + /// + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.disabled]. + /// {@endtemplate} + /// + /// If null, then the value of [PopupMenuThemeData.mouseCursor] is used. If + /// that is also null, then [MaterialStateMouseCursor.clickable] is used. + final MouseCursor? mouseCursor; + + /// The widget below this widget in the tree. + /// + /// Typically a single-line [ListTile] (for menus with icons) or a [Text]. An + /// appropriate [DefaultTextStyle] is put in scope for the child. In either + /// case, the text should be short enough that it won't wrap. + final Widget? child; + + @override + bool represents(T? value) => value == this.value; + + @override + TolyPopupMenuItemState> createState() => TolyPopupMenuItemState>(); +} + +/// The [State] for [TolyPopupMenuItem] subclasses. +/// +/// By default this implements the basic styling and layout of Material Design +/// popup menu items. +/// +/// The [buildChild] method can be overridden to adjust exactly what gets placed +/// in the menu. By default it returns [TolyPopupMenuItem.child]. +/// +/// The [handleTap] method can be overridden to adjust exactly what happens when +/// the item is tapped. By default, it uses [Navigator.pop] to return the +/// [TolyPopupMenuItem.value] from the menu route. +/// +/// This class takes two type arguments. The second, `W`, is the exact type of +/// the [Widget] that is using this [State]. It must be a subclass of +/// [TolyPopupMenuItem]. The first, `T`, must match the type argument of that widget +/// class, and is the type of values returned from this menu. +class TolyPopupMenuItemState> extends State { + /// The menu item contents. + /// + /// Used by the [build] method. + /// + /// By default, this returns [TolyPopupMenuItem.child]. Override this to put + /// something else in the menu entry. + @protected + Widget? buildChild() => widget.child; + + /// The handler for when the user selects the menu item. + /// + /// Used by the [InkWell] inserted by the [build] method. + /// + /// By default, uses [Navigator.pop] to return the [TolyPopupMenuItem.value] from + /// the menu route. + @protected + void handleTap() { + widget.onTap?.call(); + + Navigator.pop(context, widget.value); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); + final Set states = { + if (!widget.enabled) MaterialState.disabled, + }; + + TextStyle style = theme.useMaterial3 + ? (widget.labelTextStyle?.resolve(states) + ?? popupMenuTheme.labelTextStyle?.resolve(states)! + ?? defaults.labelTextStyle!.resolve(states)!) + : (widget.textStyle + ?? popupMenuTheme.textStyle + ?? defaults.textStyle!); + + if (!widget.enabled && !theme.useMaterial3) { + style = style.copyWith(color: theme.disabledColor); + } + + Widget item = AnimatedDefaultTextStyle( + style: style, + duration: kThemeChangeDuration, + child: Container( + alignment: AlignmentDirectional.centerStart, + constraints: BoxConstraints(minHeight: widget.height), + padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: _kMenuHorizontalPadding), + child: buildChild(), + ), + ); + + if (!widget.enabled) { + final bool isDark = theme.brightness == Brightness.dark; + item = IconTheme.merge( + data: IconThemeData(opacity: isDark ? 0.5 : 0.38), + child: item, + ); + } + + return MergeSemantics( + child: Semantics( + enabled: widget.enabled, + button: true, + child: InkWell( + onTap: widget.enabled ? handleTap : null, + canRequestFocus: widget.enabled, + mouseCursor: _EffectiveMouseCursor(widget.mouseCursor, popupMenuTheme.mouseCursor), + child: item, + ), + ), + ); + } +} + +/// An item with a checkmark in a Material Design popup menu. +/// +/// To show a popup menu, use the [showTolyMenu] function. To create a button that +/// shows a popup menu, consider using [TolyPopupMenuButton]. +/// +/// A [TolyCheckedPopupMenuItem] is kMinInteractiveDimension pixels high, which +/// matches the default minimum height of a [TolyPopupMenuItem]. The horizontal +/// layout uses [ListTile]; the checkmark is an [Icons.done] icon, shown in the +/// [ListTile.leading] position. +/// +/// {@tool snippet} +/// +/// Suppose a `Commands` enum exists that lists the possible commands from a +/// particular popup menu, including `Commands.heroAndScholar` and +/// `Commands.hurricaneCame`, and further suppose that there is a +/// `_heroAndScholar` member field which is a boolean. The example below shows a +/// menu with one menu item with a checkmark that can toggle the boolean, and +/// one menu item without a checkmark for selecting the second option. (It also +/// shows a divider placed between the two menu items.) +/// +/// ```dart +/// PopupMenuButton( +/// onSelected: (Commands result) { +/// switch (result) { +/// case Commands.heroAndScholar: +/// setState(() { _heroAndScholar = !_heroAndScholar; }); +/// case Commands.hurricaneCame: +/// // ...handle hurricane option +/// break; +/// // ...other items handled here +/// } +/// }, +/// itemBuilder: (BuildContext context) => >[ +/// CheckedPopupMenuItem( +/// checked: _heroAndScholar, +/// value: Commands.heroAndScholar, +/// child: const Text('Hero and scholar'), +/// ), +/// const PopupMenuDivider(), +/// const PopupMenuItem( +/// value: Commands.hurricaneCame, +/// child: ListTile(leading: Icon(null), title: Text('Bring hurricane')), +/// ), +/// // ...other items listed here +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// In particular, observe how the second menu item uses a [ListTile] with a +/// blank [Icon] in the [ListTile.leading] position to get the same alignment as +/// the item with the checkmark. +/// +/// See also: +/// +/// * [TolyPopupMenuItem], a popup menu entry for picking a command (as opposed to +/// toggling a value). +/// * [TolyPopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [showTolyMenu], a method to dynamically show a popup menu at a given location. +/// * [TolyPopupMenuButton], an [IconButton] that automatically shows a menu when +/// it is tapped. +class TolyCheckedPopupMenuItem extends TolyPopupMenuItem { + /// Creates a popup menu item with a checkmark. + /// + /// By default, the menu item is [enabled] but unchecked. To mark the item as + /// checked, set [checked] to true. + /// + /// The `checked` and `enabled` arguments must not be null. + const TolyCheckedPopupMenuItem({ + super.key, + super.value, + this.checked = false, + super.enabled, + super.padding, + super.height, + super.mouseCursor, + super.child, + }); + + /// Whether to display a checkmark next to the menu item. + /// + /// Defaults to false. + /// + /// When true, an [Icons.done] checkmark is displayed. + /// + /// When this popup menu item is selected, the checkmark will fade in or out + /// as appropriate to represent the implied new state. + final bool checked; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text]. An appropriate [DefaultTextStyle] is put in scope for + /// the child. The text should be short enough that it won't wrap. + /// + /// This widget is placed in the [ListTile.title] slot of a [ListTile] whose + /// [ListTile.leading] slot is an [Icons.done] icon. + @override + Widget? get child => super.child; + + @override + TolyPopupMenuItemState> createState() => _TolyCheckedPopupMenuItemState(); +} + +class _TolyCheckedPopupMenuItemState extends TolyPopupMenuItemState> with SingleTickerProviderStateMixin { + static const Duration _fadeDuration = Duration(milliseconds: 150); + late AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + _controller = AnimationController(duration: _fadeDuration, vsync: this) + ..value = widget.checked ? 1.0 : 0.0 + ..addListener(() => setState(() { /* animation changed */ })); + } + + @override + void handleTap() { + // This fades the checkmark in or out when tapped. + if (widget.checked) { + _controller.reverse(); + } else { + _controller.forward(); + } + super.handleTap(); + } + + @override + Widget buildChild() { + return IgnorePointer( + child: ListTile( + enabled: widget.enabled, + leading: FadeTransition( + opacity: _opacity, + child: Icon(_controller.isDismissed ? null : Icons.done), + ), + title: widget.child, + ), + ); + } +} + +class _TolyPopupMenu extends StatelessWidget { + const _TolyPopupMenu({ + super.key, + required this.route, + required this.semanticLabel, + required this.padding, + this.constraints, + required this.clipBehavior, + }); + final EdgeInsetsGeometry padding; + final _TolyPopupMenuRoute route; + final String? semanticLabel; + final BoxConstraints? constraints; + final Clip clipBehavior; + + @override + Widget build(BuildContext context) { + final double unit = 1.0 / (route.items.length + 1.5); // 1.0 for the width and 0.5 for the last item's fade. + final List children = []; + final ThemeData theme = Theme.of(context); + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final PopupMenuThemeData defaults = theme.useMaterial3 ? _PopupMenuDefaultsM3(context) : _PopupMenuDefaultsM2(context); + + for (int i = 0; i < route.items.length; i += 1) { + final double start = (i + 1) * unit; + final double end = clampDouble(start + 1.5 * unit, 0.0, 1.0); + final CurvedAnimation opacity = CurvedAnimation( + parent: route.animation!, + curve: Interval(start, end), + ); + Widget item = route.items[i]; + if (route.initialValue != null && route.items[i].represents(route.initialValue)) { + item = ColoredBox( + color: Theme.of(context).highlightColor, + child: item, + ); + } + children.add( + _MenuItem( + onLayout: (Size size) { + route.itemSizes[i] = size; + }, + child: FadeTransition( + opacity: opacity, + child: item, + ), + ), + ); + } + + final CurveTween opacity = CurveTween(curve: const Interval(0.0, 1.0 / 3.0)); + final CurveTween width = CurveTween(curve: Interval(0.0, unit)); + final CurveTween height = CurveTween(curve: Interval(0.0, unit * route.items.length)); + + final Widget child = ConstrainedBox( + constraints: constraints ?? const BoxConstraints( + minWidth: _kMenuMinWidth, + maxWidth: _kMenuMaxWidth, + ), + child: IntrinsicWidth( + stepWidth: _kMenuWidthStep, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: semanticLabel, + child: SingleChildScrollView( + padding: padding, + child: ListBody(children: children), + ), + ), + ), + ); + + return AnimatedBuilder( + animation: route.animation!, + builder: (BuildContext context, Widget? child) { + return FadeTransition( + opacity: opacity.animate(route.animation!), + child: Material( + shape: route.shape ?? popupMenuTheme.shape ?? defaults.shape, + color: Colors.transparent, + elevation: 0, + // color: route.color ?? popupMenuTheme.color ?? defaults.color, + clipBehavior: clipBehavior, + type: MaterialType.card, + // elevation: route.elevation ?? popupMenuTheme.elevation ?? defaults.elevation!, + shadowColor: route.shadowColor ?? popupMenuTheme.shadowColor ?? defaults.shadowColor, + surfaceTintColor: route.surfaceTintColor ?? popupMenuTheme.surfaceTintColor ?? defaults.surfaceTintColor, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4 + ) + ] + ), + child: Align( + alignment: AlignmentDirectional.topEnd, + widthFactor: width.evaluate(route.animation!), + heightFactor: height.evaluate(route.animation!), + child: child, + ), + ), + ), + ); + }, + child: child, + ); + } +} + +// Positioning of the menu on the screen. +class _TolyPopupMenuRouteLayout extends SingleChildLayoutDelegate { + _TolyPopupMenuRouteLayout( + this.position, + this.itemSizes, + this.selectedItemIndex, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // The sizes of each item are computed when the menu is laid out, and before + // the route is laid out. + List itemSizes; + + // The index of the selected item, or null if PopupMenuButton.initialValue + // was not specified. + final int? selectedItemIndex; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by 8. If necessary, we adjust the + // child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_kMenuScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + + final double buttonHeight = size.height - position.top - position.bottom; + // Find the ideal vertical position. + double y = position.top; + if (selectedItemIndex != null) { + double selectedItemOffset = _kMenuVerticalPadding; + for (int index = 0; index < selectedItemIndex!; index += 1) { + selectedItemOffset += itemSizes[index]!.height; + } + selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2; + y = y + buttonHeight / 2.0 - selectedItemOffset; + } + + // Find the ideal horizontal position. + double x; + if (position.left > position.right) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else if (position.left < position.right) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else { + // Menu button is equidistant from both edges, so grow in reading direction. + switch (textDirection) { + case TextDirection.rtl: + x = size.width - position.right - childSize.width; + case TextDirection.ltr: + x = position.left; + } + } + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = DisplayFeatureSubScreen.subScreensInBounds(Offset.zero & size, avoidBounds); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition){ + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _kMenuScreenPadding + padding.left) { + x = screen.left + _kMenuScreenPadding + padding.left; + } else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) { + x = screen.right - childSize.width - _kMenuScreenPadding - padding.right; + } + if (y < screen.top + _kMenuScreenPadding + padding.top) { + y = _kMenuScreenPadding + padding.top; + } else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) { + y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom; + } + + return Offset(x,y); + } + + @override + bool shouldRelayout(_TolyPopupMenuRouteLayout oldDelegate) { + // If called when the old and new itemSizes have been initialized then + // we expect them to have the same length because there's no practical + // way to change length of the items list once the menu has been shown. + assert(itemSizes.length == oldDelegate.itemSizes.length); + + return position != oldDelegate.position + || selectedItemIndex != oldDelegate.selectedItemIndex + || textDirection != oldDelegate.textDirection + || !listEquals(itemSizes, oldDelegate.itemSizes) + || padding != oldDelegate.padding + || !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} + +class _TolyPopupMenuRoute extends PopupRoute { + _TolyPopupMenuRoute({ + required this.position, + required this.items, + this.initialValue, + this.elevation, + this.surfaceTintColor, + this.shadowColor, + this.padding = EdgeInsets.zero, + required this.barrierLabel, + this.semanticLabel, + this.shape, + this.color, + required this.capturedThemes, + this.constraints, + required this.clipBehavior, + }) : itemSizes = List.filled(items.length, null), + // Menus always cycle focus through their items irrespective of the + // focus traversal edge behavior set in the Navigator. + super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop); + + final RelativeRect position; + final List> items; + final List itemSizes; + final T? initialValue; + final double? elevation; + final Color? surfaceTintColor; + final Color? shadowColor; + final String? semanticLabel; + final ShapeBorder? shape; + final Color? color; + final CapturedThemes capturedThemes; + final BoxConstraints? constraints; + final Clip clipBehavior; + final EdgeInsetsGeometry padding; + + + @override + Animation createAnimation() { + return CurvedAnimation( + parent: super.createAnimation(), + curve: Curves.linear, + reverseCurve: const Interval(0.0, _kMenuCloseIntervalEnd), + ); + } + + @override + Duration get transitionDuration => _kMenuDuration; + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String barrierLabel; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + + int? selectedItemIndex; + if (initialValue != null) { + for (int index = 0; selectedItemIndex == null && index < items.length; index += 1) { + if (items[index].represents(initialValue)) { + selectedItemIndex = index; + } + } + } + + final Widget menu = _TolyPopupMenu( + route: this, + padding: padding, + semanticLabel: semanticLabel, + constraints: constraints, + clipBehavior: clipBehavior, + ); + final MediaQueryData mediaQuery = MediaQuery.of(context); + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Builder( + builder: (BuildContext context) { + return CustomSingleChildLayout( + delegate: _TolyPopupMenuRouteLayout( + position, + itemSizes, + selectedItemIndex, + Directionality.of(context), + mediaQuery.padding, + _avoidBounds(mediaQuery), + ), + child: capturedThemes.wrap(menu), + ); + }, + ), + ); + } + + Set _avoidBounds(MediaQueryData mediaQuery) { + return DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(); + } +} + +/// Show a popup menu that contains the `items` at `position`. +/// +/// `items` should be non-null and not empty. +/// +/// If `initialValue` is specified then the first item with a matching value +/// will be highlighted and the value of `position` gives the rectangle whose +/// vertical center will be aligned with the vertical center of the highlighted +/// item (when possible). +/// +/// If `initialValue` is not specified then the top of the menu will be aligned +/// with the top of the `position` rectangle. +/// +/// In both cases, the menu position will be adjusted if necessary to fit on the +/// screen. +/// +/// Horizontally, the menu is positioned so that it grows in the direction that +/// has the most room. For example, if the `position` describes a rectangle on +/// the left edge of the screen, then the left edge of the menu is aligned with +/// the left edge of the `position`, and the menu grows to the right. If both +/// edges of the `position` are equidistant from the opposite edge of the +/// screen, then the ambient [Directionality] is used as a tie-breaker, +/// preferring to grow in the reading direction. +/// +/// The positioning of the `initialValue` at the `position` is implemented by +/// iterating over the `items` to find the first whose +/// [TolyPopupMenuEntry.represents] method returns true for `initialValue`, and then +/// summing the values of [TolyPopupMenuEntry.height] for all the preceding widgets +/// in the list. +/// +/// The `elevation` argument specifies the z-coordinate at which to place the +/// menu. The elevation defaults to 8, the appropriate elevation for popup +/// menus. +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the menu. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the popup menu is closed. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// menu to the [Navigator] furthest from or nearest to the given `context`. It +/// is `false` by default. +/// +/// The `semanticLabel` argument is used by accessibility frameworks to +/// announce screen transitions when the menu is opened and closed. If this +/// label is not provided, it will default to +/// [MaterialLocalizations.popupMenuLabel]. +/// +/// The `clipBehavior` argument is used to clip the shape of the menu. Defaults to +/// [Clip.none]. +/// +/// See also: +/// +/// * [TolyPopupMenuItem], a popup menu entry for a single value. +/// * [TolyPopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [TolyCheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [TolyPopupMenuButton], which provides an [IconButton] that shows a menu by +/// calling this method automatically. +/// * [SemanticsConfiguration.namesRoute], for a description of edge triggered +/// semantics. +Future showTolyMenu({ + required BuildContext context, + required RelativeRect position, + required List> items, + T? initialValue, + double? elevation, + Color? shadowColor, + Color? surfaceTintColor, + String? semanticLabel, + ShapeBorder? shape, + EdgeInsetsGeometry menuPadding=EdgeInsets.zero, + + Color? color, + bool useRootNavigator = false, + BoxConstraints? constraints, + Clip clipBehavior = Clip.none, +}) { + assert(items.isNotEmpty); + assert(debugCheckHasMaterialLocalizations(context)); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + semanticLabel ??= MaterialLocalizations.of(context).popupMenuLabel; + } + + final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push(_TolyPopupMenuRoute( + position: position, + items: items, + padding: menuPadding, + initialValue: initialValue, + elevation: elevation, + shadowColor: shadowColor, + surfaceTintColor: surfaceTintColor, + semanticLabel: semanticLabel, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + shape: shape, + color: color, + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + constraints: constraints, + clipBehavior: clipBehavior, + )); +} + +/// Signature for the callback invoked when a menu item is selected. The +/// argument is the value of the [TolyPopupMenuItem] that caused its menu to be +/// dismissed. +/// +/// Used by [TolyPopupMenuButton.onSelected]. +typedef PopupMenuItemSelected = void Function(T value); + +/// Signature for the callback invoked when a [TolyPopupMenuButton] is dismissed +/// without selecting an item. +/// +/// Used by [TolyPopupMenuButton.onCanceled]. +typedef PopupMenuCanceled = void Function(); + +/// Signature used by [TolyPopupMenuButton] to lazily construct the items shown when +/// the button is pressed. +/// +/// Used by [TolyPopupMenuButton.itemBuilder]. +typedef PopupMenuItemBuilder = List> Function(BuildContext context); + +/// Displays a menu when pressed and calls [onSelected] when the menu is dismissed +/// because an item was selected. The value passed to [onSelected] is the value of +/// the selected menu item. +/// +/// One of [child] or [icon] may be provided, but not both. If [icon] is provided, +/// then [TolyPopupMenuButton] behaves like an [IconButton]. +/// +/// If both are null, then a standard overflow icon is created (depending on the +/// platform). +/// +/// {@tool dartpad} +/// This example shows a menu with three items, selecting between an enum's +/// values and setting a `selectedMenu` field based on the selection. +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of a popup menu, as described in: +/// https://m3.material.io/components/menus/overview +/// +/// ** See code in examples/api/lib/material/popup_menu/popup_menu.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TolyPopupMenuItem], a popup menu entry for a single value. +/// * [TolyPopupMenuDivider], a popup menu entry that is just a horizontal line. +/// * [TolyCheckedPopupMenuItem], a popup menu item with a checkmark. +/// * [showTolyMenu], a method to dynamically show a popup menu at a given location. +class TolyPopupMenuButton extends StatefulWidget { + /// Creates a button that shows a popup menu. + /// + /// The [itemBuilder] argument must not be null. + const TolyPopupMenuButton({ + super.key, + required this.itemBuilder, + this.initialValue, + this.onOpened, + this.onSelected, + this.onCanceled, + this.tooltip, + this.elevation, + this.shadowColor, + this.surfaceTintColor, + this.padding = const EdgeInsets.all(8.0), + this.menuPadding = EdgeInsets.zero, + this.child, + this.splashRadius, + this.icon, + this.iconSize, + this.offset = Offset.zero, + this.enabled = true, + this.shape, + this.color, + this.enableFeedback, + this.constraints, + this.position, + this.clipBehavior = Clip.none, + }) : assert( + !(child != null && icon != null), + 'You can only pass [child] or [icon], not both.', + ); + + /// Called when the button is pressed to create the items to show in the menu. + final PopupMenuItemBuilder itemBuilder; + + /// The value of the menu item, if any, that should be highlighted when the menu opens. + final T? initialValue; + + /// Called when the popup menu is shown. + final VoidCallback? onOpened; + + /// Called when the user selects a value from the popup menu created by this button. + /// + /// If the popup menu is dismissed without selecting a value, [onCanceled] is + /// called instead. + final PopupMenuItemSelected? onSelected; + + /// Called when the user dismisses the popup menu without selecting an item. + /// + /// If the user selects a value, [onSelected] is called instead. + final PopupMenuCanceled? onCanceled; + + /// Text that describes the action that will occur when the button is pressed. + /// + /// This text is displayed when the user long-presses on the button and is + /// used for accessibility. + final String? tooltip; + + /// The z-coordinate at which to place the menu when open. This controls the + /// size of the shadow below the menu. + /// + /// Defaults to 8, the appropriate elevation for popup menus. + final double? elevation; + + /// The color used to paint the shadow below the menu. + /// + /// If null then the ambient [PopupMenuThemeData.shadowColor] is used. + /// If that is null too, then the overall theme's [ThemeData.shadowColor] + /// (default black) is used. + final Color? shadowColor; + + /// The color used as an overlay on [color] to indicate elevation. + /// + /// If null, [PopupMenuThemeData.surfaceTintColor] is used. If that + /// is also null, the default value is [ColorScheme.surfaceTint]. + /// + /// See [Material.surfaceTintColor] for more details on how this + /// overlay is applied. + final Color? surfaceTintColor; + + /// Matches IconButton's 8 dps padding by default. In some cases, notably where + /// this button appears as the trailing element of a list item, it's useful to be able + /// to set the padding to zero. + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry menuPadding; + + /// The splash radius. + /// + /// If null, default splash radius of [InkWell] or [IconButton] is used. + final double? splashRadius; + + /// If provided, [child] is the widget used for this button + /// and the button will utilize an [InkWell] for taps. + final Widget? child; + + /// If provided, the [icon] is used for this button + /// and the button will behave like an [IconButton]. + final Widget? icon; + + /// The offset is applied relative to the initial position + /// set by the [position]. + /// + /// When not set, the offset defaults to [Offset.zero]. + final Offset offset; + + /// Whether this popup menu button is interactive. + /// + /// Must be non-null, defaults to `true` + /// + /// If `true` the button will respond to presses by displaying the menu. + /// + /// If `false`, the button is styled with the disabled color from the + /// current [Theme] and will not respond to presses or show the popup + /// menu and [onSelected], [onCanceled] and [itemBuilder] will not be called. + /// + /// This can be useful in situations where the app needs to show the button, + /// but doesn't currently have anything to show in the menu. + final bool enabled; + + /// If provided, the shape used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.shape] is used. + /// If [PopupMenuThemeData.shape] is also null, then the default shape for + /// [MaterialType.card] is used. This default shape is a rectangle with + /// rounded edges of BorderRadius.circular(2.0). + final ShapeBorder? shape; + + /// If provided, the background color used for the menu. + /// + /// If this property is null, then [PopupMenuThemeData.color] is used. + /// If [PopupMenuThemeData.color] is also null, then + /// Theme.of(context).cardColor is used. + final Color? color; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// If provided, the size of the [Icon]. + /// + /// If this property is null, then [IconThemeData.size] is used. + /// If [IconThemeData.size] is also null, then + /// default size is 24.0 pixels. + final double? iconSize; + + /// Optional size constraints for the menu. + /// + /// When unspecified, defaults to: + /// ```dart + /// const BoxConstraints( + /// minWidth: 2.0 * 56.0, + /// maxWidth: 5.0 * 56.0, + /// ) + /// ``` + /// + /// The default constraints ensure that the menu width matches maximum width + /// recommended by the Material Design guidelines. + /// Specifying this parameter enables creation of menu wider than + /// the default maximum width. + final BoxConstraints? constraints; + + /// Whether the popup menu is positioned over or under the popup menu button. + /// + /// [offset] is used to change the position of the popup menu relative to the + /// position set by this parameter. + /// + /// If this property is `null`, then [PopupMenuThemeData.position] is used. If + /// [PopupMenuThemeData.position] is also `null`, then the position defaults + /// to [PopupMenuPosition.over] which makes the popup menu appear directly + /// over the button that was used to create it. + final PopupMenuPosition? position; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// The [clipBehavior] argument is used the clip shape of the menu. + /// + /// Defaults to [Clip.none], and must not be null. + final Clip clipBehavior; + + @override + TolyPopupMenuButtonState createState() => TolyPopupMenuButtonState(); +} + +/// The [State] for a [TolyPopupMenuButton]. +/// +/// See [showButtonMenu] for a way to programmatically open the popup menu +/// of your button state. +class TolyPopupMenuButtonState extends State> { + /// A method to show a popup menu with the items supplied to + /// [TolyPopupMenuButton.itemBuilder] at the position of your [TolyPopupMenuButton]. + /// + /// By default, it is called when the user taps the button and [TolyPopupMenuButton.enabled] + /// is set to `true`. Moreover, you can open the button by calling the method manually. + /// + /// You would access your [TolyPopupMenuButtonState] using a [GlobalKey] and + /// show the menu of the button with `globalKey.currentState.showButtonMenu`. + void showButtonMenu() { + final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context); + final RenderBox button = context.findRenderObject()! as RenderBox; + final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox; + final PopupMenuPosition popupMenuPosition = widget.position ?? popupMenuTheme.position ?? PopupMenuPosition.over; + final Offset offset; + switch (popupMenuPosition) { + case PopupMenuPosition.over: + offset = widget.offset; + case PopupMenuPosition.under: + offset = Offset(0.0, button.size.height - (widget.padding.vertical / 2)) + widget.offset; + } + final RelativeRect position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(offset, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + final List> items = widget.itemBuilder(context); + // Only show the menu if there is something to show + if (items.isNotEmpty) { + widget.onOpened?.call(); + showTolyMenu( + context: context, + elevation: widget.elevation ?? popupMenuTheme.elevation, + shadowColor: widget.shadowColor ?? popupMenuTheme.shadowColor, + surfaceTintColor: widget.surfaceTintColor ?? popupMenuTheme.surfaceTintColor, + items: items, + menuPadding: widget.menuPadding, + initialValue: widget.initialValue, + position: position, + + shape: widget.shape ?? popupMenuTheme.shape, + color: widget.color ?? popupMenuTheme.color, + constraints: widget.constraints, + clipBehavior: widget.clipBehavior, + ) + .then((T? newValue) { + // if (!mounted) { + // return null; + // } + if (newValue == null) { + widget.onCanceled?.call(); + return null; + } + widget.onSelected?.call(newValue); + }); + } + } + + bool get _canRequestFocus { + final NavigationMode mode = MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return widget.enabled; + case NavigationMode.directional: + return true; + } + } + + @override + Widget build(BuildContext context) { + final IconThemeData iconTheme = IconTheme.of(context); + final bool enableFeedback = widget.enableFeedback + ?? PopupMenuTheme.of(context).enableFeedback + ?? true; + + assert(debugCheckHasMaterialLocalizations(context)); + + if (widget.child != null) { + return Tooltip( + message: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + child: InkWell( + onTap: widget.enabled ? showButtonMenu : null, + canRequestFocus: _canRequestFocus, + radius: widget.splashRadius, + enableFeedback: enableFeedback, + child: widget.child, + ), + ); + } + + return GestureDetector( + onTap: widget.enabled ? showButtonMenu : null, + child: widget.icon ?? Icon(Icons.adaptive.more), + ); + + return IconButton( + icon: widget.icon ?? Icon(Icons.adaptive.more), + padding: widget.padding, + splashRadius: widget.splashRadius, + iconSize: widget.iconSize ?? iconTheme.size, + color: widget.color ?? iconTheme.color, + tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, + onPressed: widget.enabled ? showButtonMenu : null, + enableFeedback: enableFeedback, + ); + } +} + +// This MaterialStateProperty is passed along to the menu item's InkWell which +// resolves the property against MaterialState.disabled, MaterialState.hovered, +// MaterialState.focused. +class _EffectiveMouseCursor extends MaterialStateMouseCursor { + const _EffectiveMouseCursor(this.widgetCursor, this.themeCursor); + + final MouseCursor? widgetCursor; + final MaterialStateProperty? themeCursor; + + @override + MouseCursor resolve(Set states) { + return MaterialStateProperty.resolveAs(widgetCursor, states) + ?? themeCursor?.resolve(states) + ?? MaterialStateMouseCursor.clickable.resolve(states); + } + + @override + String get debugDescription => 'MaterialStateMouseCursor(PopupMenuItemState)'; +} + +class _PopupMenuDefaultsM2 extends PopupMenuThemeData { + _PopupMenuDefaultsM2(this.context) + : super(elevation: 8.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final TextTheme _textTheme = _theme.textTheme; + + @override + TextStyle? get textStyle => _textTheme.subtitle1; +} + +// BEGIN GENERATED TOKEN PROPERTIES - PopupMenu + +// 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 _PopupMenuDefaultsM3 extends PopupMenuThemeData { + _PopupMenuDefaultsM3(this.context) + : super(elevation: 3.0); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + late final ColorScheme _colors = _theme.colorScheme; + late final TextTheme _textTheme = _theme.textTheme; + + @override MaterialStateProperty? get labelTextStyle { + return MaterialStateProperty.resolveWith((Set states) { + final TextStyle style = _textTheme.labelLarge!; + if (states.contains(MaterialState.disabled)) { + return style.apply(color: _colors.onSurface.withOpacity(0.38)); + } + return style.apply(color: _colors.onSurface); + }); + } + + @override + Color? get color => _colors.surface; + + @override + Color? get shadowColor => _colors.shadow; + + @override + Color? get surfaceTintColor => _colors.surfaceTint; + + @override + ShapeBorder? get shape => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); +} +// END GENERATED TOKEN PROPERTIES - PopupMenu diff --git a/lib/components/toly_ui/toly_ui.dart b/lib/components/toly_ui/toly_ui.dart index 9d9a7f6..34d6178 100644 --- a/lib/components/toly_ui/toly_ui.dart +++ b/lib/components/toly_ui/toly_ui.dart @@ -2,4 +2,6 @@ export 'navigation/menu_meta.dart'; export 'navigation/toly_breadcrumb.dart'; export 'navigation/toly_navigation_rail.dart'; -export 'popable/drop_selectable_widget.dart'; \ No newline at end of file +export 'popable/drop_selectable_widget.dart'; +export 'popable/popover.dart'; +export 'popable/pop_menu.dart'; \ No newline at end of file diff --git a/lib/go_router/overlay/context_menu/context_menu_controller.0.dart b/lib/go_router/overlay/context_menu/context_menu_controller.0.dart new file mode 100644 index 0000000..99e77ec --- /dev/null +++ b/lib/go_router/overlay/context_menu/context_menu_controller.0.dart @@ -0,0 +1,183 @@ +// 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. + +// This sample demonstrates allowing a context menu to be shown in a widget +// subtree in response to user gestures. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const ContextMenuControllerExampleApp()); + +/// A builder that includes an Offset to draw the context menu at. +typedef ContextMenuBuilder = Widget Function(BuildContext context, Offset offset); + +class ContextMenuControllerExampleApp extends StatefulWidget { + const ContextMenuControllerExampleApp({super.key}); + + @override + State createState() => _ContextMenuControllerExampleAppState(); +} + +class _ContextMenuControllerExampleAppState extends State { + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => const AlertDialog(title: Text('You clicked print!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Context menu outside of text'), + ), + body: _ContextMenuRegion( + contextMenuBuilder: (BuildContext context, Offset offset) { + // The custom context menu will look like the default context menu + // on the current platform with a single 'Print' button. + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: TextSelectionToolbarAnchors( + primaryAnchor: offset, + ), + buttonItems: [ + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + label: 'Print', + ), + ], + ); + }, + // In this case this wraps a big open space in a GestureDetector in + // order to show the context menu, but it could also wrap a single + // widget like an Image to give it a context menu. + child: ListView( + children: [ + Container(height: 20.0), + const Text( + 'Right click (desktop) or long press (mobile) anywhere, not just on this text, to show the custom menu.'), + ], + ), + ), + ), + ); + } +} + +/// Shows and hides the context menu based on user gestures. +/// +/// By default, shows the menu on right clicks and long presses. +class _ContextMenuRegion extends StatefulWidget { + /// Creates an instance of [_ContextMenuRegion]. + const _ContextMenuRegion({ + required this.child, + required this.contextMenuBuilder, + }); + + /// Builds the context menu. + final ContextMenuBuilder contextMenuBuilder; + + /// The child widget that will be listened to for gestures. + final Widget child; + + @override + State<_ContextMenuRegion> createState() => _ContextMenuRegionState(); +} + +class _ContextMenuRegionState extends State<_ContextMenuRegion> { + Offset? _longPressOffset; + + final ContextMenuController _contextMenuController = ContextMenuController(); + + static bool get _longPressEnabled { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return true; + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } + } + + void _onSecondaryTapUp(TapUpDetails details) { + _show(details.globalPosition); + } + + void _onTap() { + if (!_contextMenuController.isShown) { + return; + } + _hide(); + } + + void _onLongPressStart(LongPressStartDetails details) { + _longPressOffset = details.globalPosition; + } + + void _onLongPress() { + assert(_longPressOffset != null); + _show(_longPressOffset!); + _longPressOffset = null; + } + + void _show(Offset position) { + _contextMenuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return widget.contextMenuBuilder(context, position); + }, + ); + } + + void _hide() { + _contextMenuController.remove(); + } + + @override + void dispose() { + _hide(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapUp: _onSecondaryTapUp, + onTap: _onTap, + onLongPress: _longPressEnabled ? _onLongPress : null, + onLongPressStart: _longPressEnabled ? _onLongPressStart : null, + child: widget.child, + ); + } +} diff --git a/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.0.dart b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.0.dart new file mode 100644 index 0000000..138c14d --- /dev/null +++ b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.0.dart @@ -0,0 +1,89 @@ +// 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. + +// This example demonstrates showing the default buttons, but customizing their +// appearance. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State createState() => _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState extends State { + final TextEditingController _controller = TextEditingController( + text: 'Right click (desktop) or long press (mobile) to see the menu with custom buttons.', + ); + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Custom button appearance'), + ), + body: Center( + child: Column( + children: [ + const SizedBox(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return AdaptiveTextSelectionToolbar( + anchors: editableTextState.contextMenuAnchors, + // Build the default buttons, but make them look custom. + // In a real project you may want to build different + // buttons depending on the platform. + children: editableTextState.contextMenuButtonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoButton( + borderRadius: null, + color: const Color(0xffaaaa00), + disabledColor: const Color(0xffaaaaff), + onPressed: buttonItem.onPressed, + padding: const EdgeInsets.all(10.0), + pressedOpacity: 0.7, + child: SizedBox( + width: 200.0, + child: Text( + CupertinoTextSelectionToolbarButton.getButtonLabel(context, buttonItem), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.1.dart b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.1.dart new file mode 100644 index 0000000..c7c16f0 --- /dev/null +++ b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.1.dart @@ -0,0 +1,109 @@ +// 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. + +// This example demonstrates showing a custom context menu only when some +// narrowly defined text is selected. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +const String emailAddress = 'me@example.com'; +const String text = 'Select the email address and open the menu: $emailAddress'; + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State createState() => _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState extends State { + final TextEditingController _controller = TextEditingController( + text: text, + ); + + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => const AlertDialog(title: Text('You clicked send email!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Custom button for emails'), + ), + body: Center( + child: Column( + children: [ + Container(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + final List buttonItems = editableTextState.contextMenuButtonItems; + // Here we add an "Email" button to the default TextField + // context menu for the current platform, but only if an email + // address is currently selected. + final TextEditingValue value = _controller.value; + if (_isValidEmail(value.selection.textInside(value.text))) { + buttonItems.insert( + 0, + ContextMenuButtonItem( + label: 'Send email', + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + ), + ); + } + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: buttonItems, + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +bool _isValidEmail(String text) { + return RegExp( + r'(?[a-zA-Z0-9]+)' + r'@' + r'(?[a-zA-Z0-9]+)' + r'\.' + r'(?[a-zA-Z0-9]+)', + ).hasMatch(text); +} diff --git a/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.2.dart b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.2.dart new file mode 100644 index 0000000..25fa236 --- /dev/null +++ b/lib/go_router/overlay/context_menu/editable_text_toolbar_builder.2.dart @@ -0,0 +1,132 @@ +// 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. + +// This example demonstrates how to create a custom toolbar that retains the +// look of the default buttons for the current platform. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const EditableTextToolbarBuilderExampleApp()); + +class EditableTextToolbarBuilderExampleApp extends StatefulWidget { + const EditableTextToolbarBuilderExampleApp({super.key}); + + @override + State createState() => _EditableTextToolbarBuilderExampleAppState(); +} + +class _EditableTextToolbarBuilderExampleAppState extends State { + final TextEditingController _controller = TextEditingController( + text: 'Right click (desktop) or long press (mobile) to see the menu with a custom toolbar.', + ); + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Custom toolbar, default-looking buttons'), + ), + body: Center( + child: Column( + children: [ + const SizedBox(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return _MyTextSelectionToolbar( + anchor: editableTextState.contextMenuAnchors.primaryAnchor, + // getAdaptiveButtons creates the default button widgets for + // the current platform. + children: AdaptiveTextSelectionToolbar.getAdaptiveButtons( + context, + // These buttons just close the menu when clicked. + [ + ContextMenuButtonItem( + label: 'One', + onPressed: () => ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Two', + onPressed: () => ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Three', + onPressed: () => ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Four', + onPressed: () => ContextMenuController.removeAny(), + ), + ContextMenuButtonItem( + label: 'Five', + onPressed: () => ContextMenuController.removeAny(), + ), + ], + ).toList(), + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +/// A simple, yet totally custom, text selection toolbar. +/// +/// Displays its children in a scrollable grid. +class _MyTextSelectionToolbar extends StatelessWidget { + const _MyTextSelectionToolbar({ + required this.anchor, + required this.children, + }); + + final Offset anchor; + final List children; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + top: anchor.dy, + left: anchor.dx, + child: Container( + width: 200.0, + height: 200.0, + color: Colors.cyanAccent.withOpacity(0.5), + child: GridView.count( + padding: const EdgeInsets.all(12.0), + crossAxisCount: 2, + children: children, + ), + ), + ), + ], + ); + } +} diff --git a/lib/go_router/overlay/context_menu/selectable_region_toolbar_builder.0.dart b/lib/go_router/overlay/context_menu/selectable_region_toolbar_builder.0.dart new file mode 100644 index 0000000..a63b625 --- /dev/null +++ b/lib/go_router/overlay/context_menu/selectable_region_toolbar_builder.0.dart @@ -0,0 +1,93 @@ +// 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. + +// This example demonstrates a custom context menu in non-editable text using +// SelectionArea. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const SelectableRegionToolbarBuilderExampleApp()); + +const String text = + 'I am some text inside of SelectionArea. Right click (desktop) or long press (mobile) me to show the customized context menu.'; + +class SelectableRegionToolbarBuilderExampleApp extends StatefulWidget { + const SelectableRegionToolbarBuilderExampleApp({super.key}); + + @override + State createState() => _SelectableRegionToolbarBuilderExampleAppState(); +} + +class _SelectableRegionToolbarBuilderExampleAppState extends State { + void _showDialog(BuildContext context) { + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => const AlertDialog(title: Text('You clicked print!')), + ), + ); + } + + @override + void initState() { + super.initState(); + // On web, disable the browser's context menu since this example uses a custom + // Flutter-rendered context menu. + if (kIsWeb) { + BrowserContextMenu.disableContextMenu(); + } + } + + @override + void dispose() { + if (kIsWeb) { + BrowserContextMenu.enableContextMenu(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Context menu anywhere'), + ), + body: Center( + child: SizedBox( + width: 200.0, + child: SelectionArea( + contextMenuBuilder: ( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: selectableRegionState.contextMenuAnchors, + buttonItems: [ + ...selectableRegionState.contextMenuButtonItems, + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + label: 'Print', + ), + ], + ); + }, + child: ListView( + children: const [ + SizedBox(height: 20.0), + Text(text), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0061fed..5cd4489 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:window_manager/window_manager.dart'; -import 'v6/app.dart'; +import 'v7/app.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/v6/app/navigation/router/app_router_delegate.dart b/lib/v6/app/navigation/router/app_router_delegate.dart index 4965d43..c4af8a6 100644 --- a/lib/v6/app/navigation/router/app_router_delegate.dart +++ b/lib/v6/app/navigation/router/app_router_delegate.dart @@ -1,8 +1,17 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; -import 'iroute.dart'; +import '../../../pages/color/color_detail_page.dart'; +import '../../../pages/color/color_page.dart'; +import '../../../pages/empty/empty_page.dart'; +import '../../../pages/settings/settings_page.dart'; +import '../../../pages/counter/counter_page.dart'; +import '../../../pages/user/user_page.dart'; +import '../transition/fade_transition_page.dart'; +import '../../../pages/color/color_add_page.dart'; +import 'route_history.dart'; const List kDestinationsPaths = [ '/color', @@ -17,8 +26,65 @@ class AppRouterDelegate extends RouterDelegate with ChangeNotifier { String _path = '/color'; String get path => _path; + AppRouterDelegate() { // keepAlivePath.add('/color'); + _histories.add(RouteHistory(path)); + } + + final List _histories = []; + final List _backHistories = []; + + List get histories => _histories.reversed.toList(); + + bool get hasHistory => _histories.length > 1; + + bool get hasBackHistory => _backHistories.isNotEmpty; + + /// 历史回退操作 + /// 将当前顶层移除,并加入 _backHistories 撤销列表 + /// 并转到前一路径 + void back() { + if (!hasHistory) return; + RouteHistory top = _histories.removeLast(); + _backHistories.add(top); + if (_histories.isNotEmpty) { + _path = _histories.last.path; + if (_histories.last.extra != null) { + _pathExtraMap[_path] = _histories.last.extra; + } + notifyListeners(); + } + } + + void toHistory(RouteHistory history) { + _path = history.path; + if (history.extra != null) { + _pathExtraMap[_path] = history.extra; + } + notifyListeners(); + } + + void closeHistory(int index) { + _histories.removeAt(index); + notifyListeners(); + } + + void clearHistory() { + _histories.clear(); + notifyListeners(); + } + + /// 撤销回退操作 + /// 取出回退列表的最后元素,跳转到该路径 + void revocation() { + RouteHistory target = _backHistories.removeLast(); + _path = target.path; + if (target.extra != null) { + _pathExtraMap[_path] = target.extra; + } + _histories.add(target); + notifyListeners(); } int? get activeIndex { @@ -37,48 +103,58 @@ class AppRouterDelegate extends RouterDelegate with ChangeNotifier { final List keepAlivePath = []; - FutureOr changePath(String value, - {bool forResult = false, Object? extra, bool keepAlive = false}) { - if (forResult) { - _completerMap[value] = Completer(); - } - if (keepAlive) { - if (keepAlivePath.contains(value)) { - keepAlivePath.remove(value); - } - keepAlivePath.add(value); - } - if (extra != null) { - _pathExtraMap[value] = extra; + void setPathKeepLive(String value) { + if (keepAlivePath.contains(value)) { + keepAlivePath.remove(value); } + keepAlivePath.add(value); path = value; - if (forResult) { - return _completerMap[value]!.future; - } + } + + void setPathForData(String value, dynamic data) { + _pathExtraMap[value] = data; + path = value; + } + + Future changePathForResult(String value) async { + Completer completer = Completer(); + _completerMap[value] = completer; + path = value; + return completer.future; } set path(String value) { if (_path == value) return; _path = value; + /// 将路由加入历史列表 + _addPathToHistory(value,_pathExtraMap[path]); notifyListeners(); } + void _addPathToHistory(String value, Object? extra) { + if (_histories.isNotEmpty && value == _histories.last.path) return; + _histories.add(RouteHistory( + value, + extra: _pathExtraMap[path], + )); + } + @override Widget build(BuildContext context) { return Navigator( onPopPage: _onPopPage, - pages: _buildPages(context, path), + pages: _buildPages(path), ); } - List _buildPages(BuildContext context,String path) { + List _buildPages(path) { List pages = []; - List topPages = _buildPageByPath(context,path); + List topPages = _buildPageByPath(path); if (keepAlivePath.isNotEmpty) { for (String alivePath in keepAlivePath) { if (alivePath != path) { - pages.addAll(_buildPageByPath(context,alivePath)); + pages.addAll(_buildPageByPath(alivePath)); } } @@ -91,36 +167,64 @@ class AppRouterDelegate extends RouterDelegate with ChangeNotifier { return pages; } - Page? _buildPageByPathFromTree(BuildContext context, String path) { - // 1. 根据 path 在 iroute 树中查询节点 - IRoute? iroute = root.match(path); - Object? extra = _pathExtraMap[path]; - bool keepAlive = keepAlivePath.contains(path); - bool forResult = _completerMap.containsKey(path); - Page? page; - if (iroute != null) { - page = iroute.builder?.call( - context, - IRouteData( - uri: Uri.parse(path), - extra: extra, - keepAlive: keepAlive, - forResult: forResult, - ), - ); + List _buildPageByPath(String path) { + Widget? child; + if (path.startsWith('/color')) { + return buildColorPages(path); } - return page; + + if (path == kDestinationsPaths[1]) { + child = const CounterPage(); + } + if (path == kDestinationsPaths[2]) { + child = const UserPage(); + } + if (path == kDestinationsPaths[3]) { + child = const SettingPage(); + } + return [ + FadeTransitionPage( + key: ValueKey(path), + child: child ?? const EmptyPage(), + ) + ]; } - List _buildPageByPath(BuildContext context,String path) { + List buildColorPages(String path) { List result = []; Uri uri = Uri.parse(path); - String dist = ''; for (String segment in uri.pathSegments) { - dist += '/$segment'; - Page? page = _buildPageByPathFromTree(context,dist); - if(page!=null){ - result.add(page); + if (segment == 'color') { + result.add(const FadeTransitionPage( + key: ValueKey('/color'), + child: ColorPage(), + )); + } + if (segment == 'detail') { + final Map queryParams = uri.queryParameters; + String? selectedColor = queryParams['color']; + if (selectedColor != null) { + Color color = Color(int.parse(selectedColor, radix: 16)); + result.add(FadeTransitionPage( + key: const ValueKey('/color/detail'), + child: ColorDetailPage(color: color), + )); + } else { + Color? selectedColor = _pathExtraMap[path]; + if (selectedColor != null) { + result.add(FadeTransitionPage( + key: const ValueKey('/color/detail'), + child: ColorDetailPage(color: selectedColor), + )); + _pathExtraMap.remove(path); + } + } + } + if (segment == 'add') { + result.add(const FadeTransitionPage( + key: ValueKey('/color/add'), + child: ColorAddPage(), + )); } } return result; diff --git a/lib/v6/app/navigation/router/iroute.dart b/lib/v6/app/navigation/router/iroute.dart index 6ef5328..b703af9 100644 --- a/lib/v6/app/navigation/router/iroute.dart +++ b/lib/v6/app/navigation/router/iroute.dart @@ -1,131 +1,37 @@ -import 'package:flutter/material.dart'; - -import '../../../pages/color/color_add_page.dart'; -import '../../../pages/color/color_detail_page.dart'; -import '../../../pages/color/color_page.dart'; -import '../../../pages/counter/counter_page.dart'; -import '../../../pages/user/user_page.dart'; -import '../../../pages/settings/settings_page.dart'; -import '../transition/fade_transition_page.dart'; - class IRoute { final String path; - final IRoutePageBuilder? builder; final List children; - const IRoute({ - required this.path, - this.children = const [], - this.builder, - }); + const IRoute({required this.path, this.children = const []}); @override String toString() { return 'IRoute{path: $path, children: $children}'; } - IRoute? match(String path) { - return matchRoute(this, path); + List list(){ + + return []; } - IRoute? matchRoute(IRoute route, String path) { - if (route.path == path) { - return route; - } else { - if (route.children.isNotEmpty) { - for (int i = 0; i < route.children.length; i++) { - IRoute current = route.children[i]; - IRoute? target = matchRoute(current, path); - if (target != null) { - return target; - } - } - } else { - return null; - } - } - return null; - } } -typedef IRoutePageBuilder = Page? Function( - BuildContext context, IRouteData data); -class IRouteData { - final Object? extra; - final bool forResult; - final Uri uri; - final bool keepAlive; - - IRouteData({ - this.extra, - required this.uri, - this.forResult = false, - this.keepAlive = false, - }); -} - -IRoute root = IRoute(path: '/', children: kDestinationsIRoutes); - -List kDestinationsIRoutes = [ +const List kDestinationsIRoutes = [ IRoute( path: '/color', - builder: (ctx, data) { - return const FadeTransitionPage( - key: ValueKey('/color'), - child: ColorPage(), - ); - }, children: [ - IRoute( - path: '/color/detail', - builder: (ctx, data) { - final Map queryParams = data.uri.queryParameters; - String? selectedColor = queryParams['color']; - Color color = Colors.black; - if (selectedColor != null) { - color = Color(int.parse(selectedColor, radix: 16)); - } else if (data.extra is Color) { - color = data.extra as Color; - } - return FadeTransitionPage( - key: const ValueKey('/color/detail'), - child: ColorDetailPage(color: color), - ); - }, - ), - IRoute( - path: '/color/add', - builder: (ctx, data) { - return const FadeTransitionPage( - key: ValueKey('/color/add'), - child: ColorAddPage(), - ); - }), + IRoute(path: '/color/add'), + IRoute(path: '/color/detail'), ], ), IRoute( - path: '/counter', - builder: (ctx, data) { - return const FadeTransitionPage( - key: ValueKey('/counter'), - child: CounterPage(), - ); - }), + path: '/counter', + ), IRoute( - path: '/user', - builder: (ctx, data) { - return const FadeTransitionPage( - key: ValueKey('/user'), - child: UserPage(), - ); - }), + path: '/user', + ), IRoute( - path: '/settings', - builder: (ctx, data) { - return const FadeTransitionPage( - key: ValueKey('/settings'), - child: SettingPage(), - ); - }), + path: '/settings', + ), ]; diff --git a/lib/v6/app/navigation/router/route_history.dart b/lib/v6/app/navigation/router/route_history.dart new file mode 100644 index 0000000..02b5430 --- /dev/null +++ b/lib/v6/app/navigation/router/route_history.dart @@ -0,0 +1,6 @@ +class RouteHistory{ + final String path; + final Object? extra; + + RouteHistory(this.path, { this.extra}); +} \ No newline at end of file diff --git a/lib/v6/app/navigation/views/app_navigation.dart b/lib/v6/app/navigation/views/app_navigation.dart index adcab6c..5abca82 100644 --- a/lib/v6/app/navigation/views/app_navigation.dart +++ b/lib/v6/app/navigation/views/app_navigation.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../router/app_router_delegate.dart'; import 'app_navigation_rail.dart'; -import 'app_top_bar.dart'; +import 'app_top_bar/app_top_bar.dart'; class AppNavigation extends StatelessWidget { const AppNavigation({super.key}); diff --git a/lib/v6/app/navigation/views/app_navigation_rail.dart b/lib/v6/app/navigation/views/app_navigation_rail.dart index 8ea8ace..b47b8ef 100644 --- a/lib/v6/app/navigation/views/app_navigation_rail.dart +++ b/lib/v6/app/navigation/views/app_navigation_rail.dart @@ -41,7 +41,7 @@ class _AppNavigationRailState extends State { ), tail: Padding( padding: const EdgeInsets.only(bottom: 6.0), - child: Text('V0.0.5',style: TextStyle(color: Colors.white,fontSize: 12),), + child: Text('V0.0.6',style: TextStyle(color: Colors.white,fontSize: 12),), ), backgroundColor: const Color(0xff3975c6), onDestinationSelected: _onDestinationSelected, @@ -53,9 +53,9 @@ class _AppNavigationRailState extends State { void _onDestinationSelected(int index) { if(index==1){ - router.changePath(kDestinationsPaths[index],keepAlive: true); + router.setPathKeepLive(kDestinationsPaths[index]); }else{ - router.path = kDestinationsPaths[index]; + router.path=kDestinationsPaths[index]; } } diff --git a/lib/v6/app/navigation/views/app_top_bar/app_router_editor.dart b/lib/v6/app/navigation/views/app_top_bar/app_router_editor.dart new file mode 100644 index 0000000..40516a7 --- /dev/null +++ b/lib/v6/app/navigation/views/app_top_bar/app_router_editor.dart @@ -0,0 +1,64 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:iroute/components/toly_ui/button/hover_icon_button.dart'; +import '../../router/app_router_delegate.dart'; + +class AppRouterEditor extends StatefulWidget { + final ValueChanged? onSubmit; + const AppRouterEditor({super.key, this.onSubmit}); + + @override + State createState() => _AppRouterEditorState(); +} + +class _AppRouterEditorState extends State { + + final TextEditingController _controller = TextEditingController(); + + + @override + void initState() { + super.initState(); + _onRouteChange(); + router.addListener(_onRouteChange); + } + + void _onRouteChange() { + _controller.text=router.path; + } + + @override + void dispose() { + _controller.dispose(); + router.removeListener(_onRouteChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.centerRight, + children: [ + SizedBox( + child: CupertinoTextField( + controller: _controller, + style: TextStyle(fontSize: 14), + padding: EdgeInsets.only(left:12,top: 6,bottom: 6,right: 32), + placeholder: '输入路由地址导航', + onSubmitted: widget.onSubmit, + decoration: BoxDecoration(color: Color(0xffF1F2F3),borderRadius: BorderRadius.circular(6)), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: HoverIconButton( + icon: Icons.directions_outlined, + defaultColor: Color(0xff68696B), + onPressed:()=>widget.onSubmit?.call(_controller.text), + size: 20 + ), + ) + ], + ); + } +} diff --git a/lib/v6/app/navigation/views/app_top_bar/app_top_bar.dart b/lib/v6/app/navigation/views/app_top_bar/app_top_bar.dart new file mode 100644 index 0000000..cd9bb25 --- /dev/null +++ b/lib/v6/app/navigation/views/app_top_bar/app_top_bar.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../../router/app_router_delegate.dart'; +import 'app_router_editor.dart'; +import 'history_view_icon.dart'; +import 'route_history_button.dart'; + +class AppTopBar extends StatelessWidget { + const AppTopBar({super.key}); + + @override + Widget build(BuildContext context) { + return DragToMoveWrap( + child: Container( + alignment: Alignment.center, + height: 46, + child: Row( + children: [ + const SizedBox(width: 16), + const RouterIndicator(), + Expanded( + child: Row(children: [ + const Spacer(), + RouteHistoryButton(), + const SizedBox(width: 12,), + SizedBox( + width: 250, + child: AppRouterEditor( + onSubmit: (path) => router.path = path, + )), + const SizedBox(width: 12,), + HistoryViewIcon(), + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: VerticalDivider( + width: 32, + ), + ) + ])), + const WindowButtons() + ], + ), + ), + ); + } +} + +class RouterIndicator extends StatefulWidget { + const RouterIndicator({super.key}); + + @override + State createState() => _RouterIndicatorState(); +} + +Map kRouteLabelMap = { + '/color': '颜色板', + '/color/add': '添加颜色', + '/color/detail': '颜色详情', + '/counter': '计数器', + '/user': '我的', + '/settings': '系统设置', +}; + +class _RouterIndicatorState extends State { + @override + void initState() { + super.initState(); + router.addListener(_onRouterChange); + } + + @override + void dispose() { + router.removeListener(_onRouterChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TolyBreadcrumb( + items: pathToBreadcrumbItems(router.path), + onTapItem: (item) { + if (item.to != null) { + router.path = item.to!; + } + }, + ); + } + + void _onRouterChange() { + setState(() {}); + } + + List pathToBreadcrumbItems(String path) { + Uri uri = Uri.parse(path); + List result = []; + String to = ''; + + String distPath = ''; + for (String segment in uri.pathSegments) { + distPath += '/$segment'; + } + + for (String segment in uri.pathSegments) { + to += '/$segment'; + String label = kRouteLabelMap[to] ?? '未知路由'; + result.add(BreadcrumbItem(to: to, label: label, active: to == distPath)); + } + return result; + } +} diff --git a/lib/v6/app/navigation/views/app_top_bar/history_view_icon.dart b/lib/v6/app/navigation/views/app_top_bar/history_view_icon.dart new file mode 100644 index 0000000..e7dd0a5 --- /dev/null +++ b/lib/v6/app/navigation/views/app_top_bar/history_view_icon.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../../router/app_router_delegate.dart'; +import '../../router/route_history.dart'; +import 'app_top_bar.dart'; + +class HistoryViewIcon extends StatelessWidget{ + const HistoryViewIcon({super.key}); + + @override + Widget build(BuildContext context) { + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: PopPanel( + offset: const Offset(0, 10), + panel: SizedBox( + height: 350, + child: Column( + children: [ + _buildTopBar(), + const Expanded( + child:HistoryPanel(), + ), + ], + ), + ), + child: const Icon( + Icons.history, + size: 20, + ), + ), + ); + } + + Widget _buildTopBar() { + return Container( + decoration: BoxDecoration( + color: const Color(0xffFAFAFC), + borderRadius: BorderRadius.circular(6), + ), + padding: + const EdgeInsets.only(top: 10, left: 12, right: 12, bottom: 8), + child: Row( + children: [ + const Text( + '浏览历史', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const Spacer(), + TextButton(onPressed: router.clearHistory, child: const Text('清空历史')) + ], + )); + } +} + +class HistoryItem extends StatefulWidget { + final RouteHistory history; + final VoidCallback onPressed; + final VoidCallback onDelete; + + const HistoryItem({super.key, required this.history, required this.onPressed, required this.onDelete}); + + @override + State createState() => _HistoryItemState(); +} + +class _HistoryItemState extends State { + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.history.path), + const SizedBox( + height: 2, + ), + Text(kRouteLabelMap[widget.history.path]!), + ], + )), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close, + size: 18, + color: Color(0xff8E92A9), + ), + ), + ], + ), + ), + ); + } +} + +class HistoryPanel extends StatefulWidget { + const HistoryPanel({super.key}); + + @override + State createState() => _HistoryPanelState(); +} + +class _HistoryPanelState extends State { + + @override + void initState() { + super.initState(); + router.addListener(_onChange); + } + + @override + void dispose() { + router.removeListener(_onChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if(router.histories.isEmpty){ + return const Center( + child: Text( + '暂无浏览历史记录', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + itemExtent: 46, + itemCount: router.histories.length, + itemBuilder: (_, index) => + HistoryItem( + onDelete: (){ + int fixIndex = router.histories.length - 1 - index; + router.closeHistory(fixIndex); + }, + onPressed: (){ + router.toHistory(router.histories[index]); + Navigator.of(context).pop(); + }, + history: router.histories[index]), + ); + } + + void _onChange() { + setState(() {}); + } +} diff --git a/lib/v6/app/navigation/views/app_top_bar/route_history_button.dart b/lib/v6/app/navigation/views/app_top_bar/route_history_button.dart new file mode 100644 index 0000000..e02439f --- /dev/null +++ b/lib/v6/app/navigation/views/app_top_bar/route_history_button.dart @@ -0,0 +1,58 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:iroute/components/toly_ui/button/hover_icon_button.dart'; +import '../../router/app_router_delegate.dart'; + +class RouteHistoryButton extends StatefulWidget { + const RouteHistoryButton({super.key}); + + @override + State createState() => _RouteHistoryButtonState(); +} + +class _RouteHistoryButtonState extends State { + @override + void initState() { + super.initState(); + router.addListener(_onChange); + } + + @override + void dispose() { + router.removeListener(_onChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool hasHistory = router.hasHistory; + bool hasBackHistory = router.hasBackHistory; + Color activeColor = const Color(0xff9195AC); + Color inActiveColor = const Color(0xffC7CAD5); + Color historyColor = hasHistory?activeColor:inActiveColor; + Color backHistoryColor = hasBackHistory?activeColor:inActiveColor; + return Wrap( + children: [ + HoverIconButton( + size: 20, + hoverColor: historyColor, + defaultColor: historyColor, + icon: CupertinoIcons.arrow_left_circle, + onPressed: hasHistory?router.back:null, + ), + const SizedBox(width: 8,), + HoverIconButton( + size: 20, + hoverColor: backHistoryColor, + defaultColor: backHistoryColor, + icon: CupertinoIcons.arrow_right_circle, + onPressed: hasBackHistory?router.revocation:null, + ), + ], + ); + } + + void _onChange() { + setState(() {}); + } +} diff --git a/lib/v6/pages/color/color_page.dart b/lib/v6/pages/color/color_page.dart index 85239d3..e7b88da 100644 --- a/lib/v6/pages/color/color_page.dart +++ b/lib/v6/pages/color/color_page.dart @@ -38,11 +38,12 @@ class _ColorPageState extends State { void _selectColor(Color color){ // String value = color.value.toRadixString(16); // router.path = '/color/detail?color=$value'; - router.changePath('/color/detail',extra: color); + router.setPathForData('/color/detail',color); + } void _toAddPage() async { - Color? color = await router.changePath('/color/add',forResult: true); + Color? color = await router.changePathForResult('/color/add'); if (color != null) { setState(() { _colors.add(color); diff --git a/lib/v6_result_/app.dart b/lib/v6_result_/app.dart new file mode 100644 index 0000000..c9460c2 --- /dev/null +++ b/lib/v6_result_/app.dart @@ -0,0 +1 @@ +export 'app/unit_app.dart'; \ No newline at end of file diff --git a/lib/v6_result_/app/navigation/router/app_router_delegate.dart b/lib/v6_result_/app/navigation/router/app_router_delegate.dart new file mode 100644 index 0000000..45641af --- /dev/null +++ b/lib/v6_result_/app/navigation/router/app_router_delegate.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'iroute.dart'; +import 'route_history.dart'; + +const List kDestinationsPaths = [ + '/color', + '/counter', + '/user', + '/settings', +]; + +AppRouterDelegate router = AppRouterDelegate(); + +class AppRouterDelegate extends RouterDelegate with ChangeNotifier { + String _path = '/color'; + + String get path => _path; + + AppRouterDelegate() { + // keepAlivePath.add('/color'); + } + + int? get activeIndex { + if (path.startsWith('/color')) return 0; + if (path.startsWith('/counter')) return 1; + if (path.startsWith('/user')) return 2; + if (path.startsWith('/settings')) return 3; + return null; + } + + final List _histories = []; + final List _backHistories = []; + + bool get hasHistory => _histories.length > 1; + + bool get hasBackHistory => _backHistories.isNotEmpty; + + final Map> _completerMap = {}; + + Completer? completer; + + final Map _pathExtraMap = {}; + + final List keepAlivePath = []; + + /// 历史回退操作 + /// 将当前顶层移除,并加入 _backHistories 撤销列表 + /// 并转到前一路径 + void back() { + if (!hasHistory) return; + RouteHistory top = _histories.removeLast(); + _backHistories.add(top); + if (_histories.isNotEmpty) { + _path = _histories.last.path; + if(_histories.last.extra!=null){ + _pathExtraMap[_path] = _histories.last.extra; + } + notifyListeners(); + } + } + + /// 撤销回退操作 + /// 取出回退列表的最后元素,跳转到该路径 + void revocation() { + RouteHistory target = _backHistories.removeLast(); + _path = target.path; + if(target.extra!=null){ + _pathExtraMap[_path] = target.extra; + } + _histories.add(target); + notifyListeners(); + // changePath( + // target.path, + // extra: target.extra, + // ); + } + + FutureOr changePath( + String value, { + bool forResult = false, + Object? extra, + bool keepAlive = false, + bool recordHistory = false, + }) { + if (forResult) { + _completerMap[value] = Completer(); + } + if (keepAlive) { + if (keepAlivePath.contains(value)) { + keepAlivePath.remove(value); + } + keepAlivePath.add(value); + } + if (extra != null) { + _pathExtraMap[value] = extra; + } + + if (recordHistory) { + _histories.add(RouteHistory( + value, + extra: extra, + )); + } + + path = value; + if (forResult) { + return _completerMap[value]!.future; + } + } + + set path(String value) { + if (_path == value) return; + _path = value; + notifyListeners(); + } + + @override + Widget build(BuildContext context) { + return Navigator( + onPopPage: _onPopPage, + pages: _buildPages(context, path), + ); + } + + List _buildPages(BuildContext context, String path) { + List pages = []; + List topPages = _buildPageByPathFromTree(context, path); + + if (keepAlivePath.isNotEmpty) { + for (String alivePath in keepAlivePath) { + if (alivePath != path) { + pages.addAll(_buildPageByPathFromTree(context, alivePath)); + } + } + + /// 去除和 topPages 中重复的界面 + pages.removeWhere( + (element) => topPages.map((e) => e.key).contains(element.key)); + } + + pages.addAll(topPages); + return pages; + } + + List _buildPageByPathFromTree(BuildContext context, String path) { + List result = []; + List iRoutes = root.find(path); + if (iRoutes.isNotEmpty) { + for (int i = 0; i < iRoutes.length; i++) { + IRoute iroute = iRoutes[i]; + String path = iroute.path; + Object? extra = _pathExtraMap[path]; + bool keepAlive = keepAlivePath.contains(path); + bool forResult = _completerMap.containsKey(path); + Page? page = iroute.builder?.call( + context, + IRouteData( + uri: Uri.parse(path), + extra: extra, + keepAlive: keepAlive, + forResult: forResult, + ), + ); + if (page != null) { + result.add(page); + } + } + } + return result; + } + + @override + Future popRoute() async { + print('=======popRoute========='); + return true; + } + + bool _onPopPage(Route route, result) { + if (_completerMap.containsKey(path)) { + _completerMap[path]?.complete(result); + _completerMap.remove(path); + } + + path = backPath(path); + return route.didPop(result); + } + + String backPath(String path) { + Uri uri = Uri.parse(path); + if (uri.pathSegments.length == 1) return path; + List parts = List.of(uri.pathSegments)..removeLast(); + return '/${parts.join('/')}'; + } + + @override + Future setNewRoutePath(configuration) async {} +} diff --git a/lib/v6_result_/app/navigation/router/iroute.dart b/lib/v6_result_/app/navigation/router/iroute.dart new file mode 100644 index 0000000..5c417ae --- /dev/null +++ b/lib/v6_result_/app/navigation/router/iroute.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../../../pages/color/color_add_page.dart'; +import '../../../pages/color/color_detail_page.dart'; +import '../../../pages/color/color_page.dart'; +import '../../../pages/counter/counter_page.dart'; +import '../../../pages/user/user_page.dart'; +import '../../../pages/settings/settings_page.dart'; +import '../transition/fade_transition_page.dart'; + +class IRoute { + final String path; + final IRoutePageBuilder? builder; + final List children; + + const IRoute({ + required this.path, + this.children = const [], + this.builder, + }); + + @override + String toString() { + return 'IRoute{path: $path, children: $children}'; + } + + IRoute? match(String path) { + return matchRoute(this, path); + } + + List find(String input){ + String fixInput = input.substring(1); + List nodes = findNodes(this,fixInput,0,'/',[]); + if(nodes.isNotEmpty&&nodes.last.path!=input){ + return []; + } + return nodes; + } + + List findNodes(IRoute node,String input,int deep,String prefix,List result){ + String separator = '/'; + + List parts = input.split(separator); + if(deep>parts.length-1){ + return result; + } + String target = parts[deep]; + if(node.children.isNotEmpty){ + List nodes = node.children.where((e) => e.path==prefix+target).toList(); + bool match = nodes.isNotEmpty; + if(match){ + IRoute matched = nodes.first; + result.add(matched); + String nextPrefix = '${matched.path}$separator'; + findNodes(matched, input, ++deep,nextPrefix,result); + } + }else{ + return result; + } + return result; + } + + // List findNodes(IRoute node,String input,int deep,String prefix,List result){ + // String separator = '/'; + // List parts = input.split(separator); + // if(deep>parts.length-1){ + // return result; + // } + // String target = parts[deep]; + // if(node.children.isNotEmpty){ + // List nodes = node.children.where((e) => e.path==prefix+target).toList(); + // bool match = nodes.isNotEmpty; + // if(match){ + // IRoute matched = nodes.first; + // result.add(matched); + // String nextPrefix = '${matched.path}$separator'; + // findNodes(matched, input, ++deep,nextPrefix,result); + // } + // }else{ + // return result; + // } + // return result; + // } + + IRoute? matchRoute(IRoute route, String path) { + if (route.path == path) { + return route; + } else { + if (route.children.isNotEmpty) { + for (int i = 0; i < route.children.length; i++) { + IRoute current = route.children[i]; + IRoute? target = matchRoute(current, path); + if (target != null) { + return target; + } + } + } else { + return null; + } + } + return null; + } +} + +typedef IRoutePageBuilder = Page? Function( + BuildContext context, IRouteData data); + +class IRouteData { + final Object? extra; + final bool forResult; + final Uri uri; + final bool keepAlive; + + IRouteData({ + this.extra, + required this.uri, + this.forResult = false, + this.keepAlive = false, + }); +} + +IRoute root = IRoute(path: 'root', children: kDestinationsIRoutes); + +List kDestinationsIRoutes = [ + IRoute( + path: '/color', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/color'), + child: ColorPage(), + ); + }, + children: [ + IRoute( + path: '/color/detail', + builder: (ctx, data) { + final Map queryParams = data.uri.queryParameters; + String? selectedColor = queryParams['color']; + Color color = Colors.black; + if (selectedColor != null) { + color = Color(int.parse(selectedColor, radix: 16)); + } else if (data.extra is Color) { + color = data.extra as Color; + } + return FadeTransitionPage( + key: const ValueKey('/color/detail'), + child: ColorDetailPage(color: color), + ); + }, + ), + IRoute( + path: '/color/add', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/color/add'), + child: ColorAddPage(), + ); + }), + ], + ), + IRoute( + path: '/counter', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/counter'), + child: CounterPage(), + ); + }), + IRoute( + path: '/user', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/user'), + child: UserPage(), + ); + }), + IRoute( + path: '/settings', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/settings'), + child: SettingPage(), + ); + }), +]; diff --git a/lib/v6_result_/app/navigation/router/route_history.dart b/lib/v6_result_/app/navigation/router/route_history.dart new file mode 100644 index 0000000..02b5430 --- /dev/null +++ b/lib/v6_result_/app/navigation/router/route_history.dart @@ -0,0 +1,6 @@ +class RouteHistory{ + final String path; + final Object? extra; + + RouteHistory(this.path, { this.extra}); +} \ No newline at end of file diff --git a/lib/v6_result_/app/navigation/transition/fade_transition_page.dart b/lib/v6_result_/app/navigation/transition/fade_transition_page.dart new file mode 100644 index 0000000..552171b --- /dev/null +++ b/lib/v6_result_/app/navigation/transition/fade_transition_page.dart @@ -0,0 +1,53 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class FadeTransitionPage extends Page { + final Widget child; + final Duration duration; + + const FadeTransitionPage({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 300), + }); + + @override + Route createRoute(BuildContext context) => + PageBasedFadeTransitionRoute(this); +} + +class PageBasedFadeTransitionRoute extends PageRoute { + final FadeTransitionPage _page; + + PageBasedFadeTransitionRoute(this._page) : super(settings: _page); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => _page.duration; + + @override + bool get maintainState => true; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + var curveTween = CurveTween(curve: Curves.easeIn); + return FadeTransition( + opacity: animation.drive(curveTween), + child: (settings as FadeTransitionPage).child, + ); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) => + child; +} diff --git a/lib/v6_result_/app/navigation/transition/no_transition_page.dart b/lib/v6_result_/app/navigation/transition/no_transition_page.dart new file mode 100644 index 0000000..291910b --- /dev/null +++ b/lib/v6_result_/app/navigation/transition/no_transition_page.dart @@ -0,0 +1,47 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class NoTransitionPage extends Page { + final Widget child; + + const NoTransitionPage({ + super.key, + required this.child, + }); + + @override + Route createRoute(BuildContext context) => NoTransitionRoute(this); +} + +class NoTransitionRoute extends PageRoute { + + final NoTransitionPage _page; + + NoTransitionRoute(this._page) : super(settings: _page); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 0); + + @override + bool get maintainState => true; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + return (settings as NoTransitionPage).child; + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) => + child; +} diff --git a/lib/v6_result_/app/navigation/views/app_navigation.dart b/lib/v6_result_/app/navigation/views/app_navigation.dart new file mode 100644 index 0000000..adcab6c --- /dev/null +++ b/lib/v6_result_/app/navigation/views/app_navigation.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import '../router/app_router_delegate.dart'; +import 'app_navigation_rail.dart'; +import 'app_top_bar.dart'; + +class AppNavigation extends StatelessWidget { + const AppNavigation({super.key}); + + @override + Widget build(BuildContext context) { + double px1 = 1/View.of(context).devicePixelRatio; + return Scaffold( + body: Row( + children: [ + const AppNavigationRail(), + Expanded( + child: Column( + children: [ + const AppTopBar(), + Divider(height: px1,), + Expanded( + child: Router( + routerDelegate: router, + backButtonDispatcher: RootBackButtonDispatcher(), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/v6_result_/app/navigation/views/app_navigation_rail.dart b/lib/v6_result_/app/navigation/views/app_navigation_rail.dart new file mode 100644 index 0000000..8ea8ace --- /dev/null +++ b/lib/v6_result_/app/navigation/views/app_navigation_rail.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../router/app_router_delegate.dart'; + +class AppNavigationRail extends StatefulWidget { + const AppNavigationRail({super.key}); + + @override + State createState() => _AppNavigationRailState(); +} + +class _AppNavigationRailState extends State { + + final List deskNavBarMenus = const [ + MenuMeta(label: '颜色板', icon: Icons.color_lens_outlined), + MenuMeta(label: '计数器', icon: Icons.add_chart), + MenuMeta(label: '我的', icon: Icons.person), + MenuMeta(label: '设置', icon: Icons.settings), + ]; + + @override + void initState() { + super.initState(); + router.addListener(_onRouterChange); + } + + @override + void dispose() { + router.removeListener(_onRouterChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DragToMoveWrap( + child: TolyNavigationRail( + items: deskNavBarMenus, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 18.0), + child: FlutterLogo(), + ), + tail: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Text('V0.0.5',style: TextStyle(color: Colors.white,fontSize: 12),), + ), + backgroundColor: const Color(0xff3975c6), + onDestinationSelected: _onDestinationSelected, + selectedIndex: router.activeIndex, + ), + ); + + } + + void _onDestinationSelected(int index) { + if(index==1){ + router.changePath(kDestinationsPaths[index],keepAlive: true); + }else{ + router.path = kDestinationsPaths[index]; + } + } + + void _onRouterChange() { + setState(() {}); + } +} diff --git a/lib/v6/app/navigation/views/app_router_editor.dart b/lib/v6_result_/app/navigation/views/app_router_editor.dart similarity index 100% rename from lib/v6/app/navigation/views/app_router_editor.dart rename to lib/v6_result_/app/navigation/views/app_router_editor.dart diff --git a/lib/v6/app/navigation/views/app_top_bar.dart b/lib/v6_result_/app/navigation/views/app_top_bar.dart similarity index 100% rename from lib/v6/app/navigation/views/app_top_bar.dart rename to lib/v6_result_/app/navigation/views/app_top_bar.dart diff --git a/lib/v6_result_/app/unit_app.dart b/lib/v6_result_/app/unit_app.dart new file mode 100644 index 0000000..1a21114 --- /dev/null +++ b/lib/v6_result_/app/unit_app.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'navigation/router/app_router_delegate.dart'; +import 'navigation/views/app_navigation.dart'; +import 'navigation/views/app_navigation_rail.dart'; + +class UnitApp extends StatelessWidget { + const UnitApp({super.key}); + + @override + Widget build(BuildContext context) { + + return MaterialApp( + theme: ThemeData( + fontFamily: "宋体", + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme( + elevation: 0, + iconTheme: IconThemeData(color: Colors.black), + titleTextStyle: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ))), + debugShowCheckedModeBanner: false, + home: AppNavigation() + ); + } +} + + diff --git a/lib/v6_result_/pages/color/color_add_page.dart b/lib/v6_result_/pages/color/color_add_page.dart new file mode 100644 index 0000000..48e6dc6 --- /dev/null +++ b/lib/v6_result_/pages/color/color_add_page.dart @@ -0,0 +1,99 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; + +class ColorAddPage extends StatefulWidget { + const ColorAddPage({super.key}); + + @override + State createState() => _ColorAddPageState(); +} + +class _ColorAddPageState extends State { + late Color _color; + + @override + void initState() { + super.initState(); + _color = randomColor; + } + + @override + Widget build(BuildContext context) { + String text = '# ${_color.value.toRadixString(16)}'; + return Scaffold( + bottomNavigationBar: Container( + margin: EdgeInsets.only(right:20,bottom: 20), + // color: Colors.redAccent, + child: Row( + textDirection:TextDirection.rtl, + children: [ + ElevatedButton(onPressed: (){ + Navigator.of(context).pop(_color); + + }, child: Text('添加')), + SizedBox(width: 12,), + OutlinedButton(onPressed: (){ + Navigator.of(context).pop(); + }, child: Text('取消')), + ], + ), + ), + body: Column( + // mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0,vertical: 16), + child: Row( + children: [ + Expanded(child: Text(text,style: TextStyle(color: _color,fontSize: 24,letterSpacing: 4),)), + Container( + margin: EdgeInsets.only(left: 10), + width: 40, + height: 40, + child: Icon( + Icons.sell_outlined, + color: Colors.white, + ), + decoration: BoxDecoration( + color: _color, + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ), + ColorPicker( + colorPickerWidth:200, + // enableAlpha: false, + displayThumbColor:true, + pickerColor: _color, + paletteType: PaletteType.hueWheel, + onColorChanged: changeColor, + + ), + ], + ), + ); + } + + Random _random = Random(); + + Color get randomColor { + return Color.fromARGB( + 255, + _random.nextInt(256), + _random.nextInt(256), + _random.nextInt(256), + ); + } + + + void changeColor(Color value) { + _color = value; + setState(() { + + }); + } +} diff --git a/lib/v6_result_/pages/color/color_detail_page.dart b/lib/v6_result_/pages/color/color_detail_page.dart new file mode 100644 index 0000000..7dfed86 --- /dev/null +++ b/lib/v6_result_/pages/color/color_detail_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ColorDetailPage extends StatelessWidget { + final Color color; + const ColorDetailPage({super.key, required this.color}); + + @override + Widget build(BuildContext context) { + + const TextStyle style = TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white + ); + String text = '# ${color.value.toRadixString(16)}'; + return Scaffold( + body: Container( + alignment: Alignment.center, + color: color, + child: Text(text ,style: style,), + ), + ); + } +} diff --git a/lib/v6_result_/pages/color/color_page.dart b/lib/v6_result_/pages/color/color_page.dart new file mode 100644 index 0000000..85239d3 --- /dev/null +++ b/lib/v6_result_/pages/color/color_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/project/colors_panel.dart'; +import '../../app/navigation/router/app_router_delegate.dart'; + +class ColorPage extends StatefulWidget { + const ColorPage({super.key}); + + @override + State createState() => _ColorPageState(); +} + +class _ColorPageState extends State { + final List _colors = [ + Colors.red, Colors.black, Colors.blue, Colors.green, Colors.orange, + Colors.pink, Colors.purple, Colors.indigo, Colors.amber, Colors.cyan, + Colors.redAccent, Colors.grey, Colors.blueAccent, Colors.greenAccent, Colors.orangeAccent, + Colors.pinkAccent, Colors.purpleAccent, Colors.indigoAccent, Colors.amberAccent, Colors.cyanAccent, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + // appBar: AppBar(title:const Text('颜色主页')), + floatingActionButton: FloatingActionButton( + onPressed: _toAddPage, + child: const Icon(Icons.add), + ), + body: Align( + alignment: Alignment.topCenter, + child: ColorsPanel( + colors: _colors, + onSelect: _selectColor, + ), + ), + ); + } + + void _selectColor(Color color){ + // String value = color.value.toRadixString(16); + // router.path = '/color/detail?color=$value'; + router.changePath('/color/detail',extra: color); + } + + void _toAddPage() async { + Color? color = await router.changePath('/color/add',forResult: true); + if (color != null) { + setState(() { + _colors.add(color); + }); + } + } +} \ No newline at end of file diff --git a/lib/v6_result_/pages/counter/counter_page.dart b/lib/v6_result_/pages/counter/counter_page.dart new file mode 100644 index 0000000..b5b2e17 --- /dev/null +++ b/lib/v6_result_/pages/counter/counter_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class CounterPage extends StatefulWidget { + const CounterPage({super.key}); + + @override + State createState() => _CounterPageState(); +} + +class _CounterPageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + ); + } +} \ No newline at end of file diff --git a/lib/v6_result_/pages/empty/empty_page.dart b/lib/v6_result_/pages/empty/empty_page.dart new file mode 100644 index 0000000..b05f56f --- /dev/null +++ b/lib/v6_result_/pages/empty/empty_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class EmptyPage extends StatelessWidget { + const EmptyPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // appBar: AppBar( + // title: Text('界面走丢了'), + // ), + body: Scaffold( + body: Center( + child: Wrap( + spacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + direction: Axis.vertical, + children: [ + Icon(Icons.nearby_error,size: 64, color: Colors.grey), + Text( + '404 界面丢失', + style: TextStyle(fontSize: 24, color: Colors.grey), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/v6_result_/pages/settings/settings_page.dart b/lib/v6_result_/pages/settings/settings_page.dart new file mode 100644 index 0000000..0b53503 --- /dev/null +++ b/lib/v6_result_/pages/settings/settings_page.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class SettingPage extends StatelessWidget { + const SettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body:Center(child: Text('SettingPage'))); + } +} diff --git a/lib/v6_result_/pages/sort/sort_page.dart b/lib/v6_result_/pages/sort/sort_page.dart new file mode 100644 index 0000000..d440071 --- /dev/null +++ b/lib/v6_result_/pages/sort/sort_page.dart @@ -0,0 +1,859 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class SortPage extends StatefulWidget { + const SortPage({Key? key}) : super(key: key); + + @override + State createState() => _SortPageState(); +} + +class _SortPageState extends State { + //存放随机数组 + List numbers = []; + + //订阅流 + StreamController> streamController = StreamController(); + String currentSort = 'bubble'; + + //柱子的数量 -> 生成排序数组的长度 + double sampleSize = 0; + + //是否排序 + bool isSorted = false; + + //是否在排序中 + bool isSorting = false; + + //排序动画更新的速度 + int speed = 0; + + static int duration = 1500; + + String getTitle() { + switch (currentSort) { + case "bubble": + return "Bubble Sort"; + case "coctail": + return "Coctail Sort"; + case "comb": + return "Comb Sort"; + case "pigeonhole": + return "Pigeonhole Sort"; + case "shell": + return "Shell Sort"; + case "selection": + return "Selection Sort"; + case "cycle": + return "Cycle Sort"; + case "heap": + return "Heap Sort"; + case "insertion": + return "Insertion Sort"; + case "gnome": + return "Gnome Sort"; + case "oddeven": + return "OddEven Sort"; + case "quick": + return "Quick Sort"; + case "merge": + return "Merge Sort"; + } + return ""; + } + + reset() { + isSorted = false; + numbers = []; + for (int i = 0; i < sampleSize; ++i) { + numbers.add(Random().nextInt(500)); + } + streamController.add(numbers); + } + + Duration getDuration() { + return Duration(microseconds: duration); + } + + ///动画时间 + changeSpeed() { + if (speed >= 3) { + speed = 0; + duration = 1500; + } else { + speed++; + duration = duration ~/ 2; + } + setState(() {}); + } + + ///冒泡排序 + bubbleSort() async { + //控制需要进行排序的次数。每一轮循环都会确定一个数字的最终位置。 + for (int i = 0; i < numbers.length; ++i) { + //遍历当前未排序的元素,通过相邻的元素比较并交换位置来完成排序。 + for (int j = 0; j < numbers.length - i - 1; ++j) { + //如果 _numbers[j] 大于 _numbers[j + 1],则交换它们的位置,确保较大的元素移到右边。 + if (numbers[j] > numbers[j + 1]) { + int temp = numbers[j]; + numbers[j] = numbers[j + 1]; + numbers[j + 1] = temp; + } + //实现一个延迟,以便在ui上展示排序的动画效果 + await Future.delayed(getDuration(), () {}); + streamController.add(numbers); + } + } + } + + ///鸡尾酒排序(双向冒泡排序) + cocktailSort() async { + bool swapped = true; // 表示是否进行了交换 + int start = 0; // 当前未排序部分的起始位置 + int end = numbers.length; // 当前未排序部分的结束位置 + + // 开始排序循环,只有当没有进行交换时才会退出循环 + while (swapped == true) { + swapped = false; + + // 从左往右遍历需要排序的部分 + for (int i = start; i < end - 1; ++i) { + // 对每两个相邻元素进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果前面的元素大于后面的元素,则交换它们的位置 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + swapped = true; // 进行了交换 + } + + // 实现动画效果,延迟一段时间后更新数组状态 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + // 如果没有进行交换,则说明已经排好序,退出循环 + if (swapped == false) break; + // 重设为false,准备进行下一轮排序 + swapped = false; + // 将end设置为上一轮排序的最后一个元素的位置 + end = end - 1; + + // 从右往左遍历需要排序的部分 + for (int i = end - 1; i >= start; i--) { + // 对每两个相邻元素进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果前面的元素大于后面的元素,则交换它们的位置 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + swapped = true; // 进行了交换 + } + + // 实现动画效果,延迟一段时间后更新数组状态 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + // 将start向右移一位,准备下一轮排序 + start = start + 1; + } + } + + ///梳排序(Comb Sort) + combSort() async { + int gap = numbers.length; + + bool swapped = true; + + // 当间隔不为1或存在交换时执行循环 + while (gap != 1 || swapped == true) { + // 通过缩小间隔来逐步将元素归位 + gap = getNextGap(gap); + swapped = false; + for (int i = 0; i < numbers.length - gap; i++) { + // 如果当前元素大于间隔位置上的元素,则交换它们的位置 + if (numbers[i] > numbers[i + gap]) { + int temp = numbers[i]; + numbers[i] = numbers[i + gap]; + numbers[i + gap] = temp; + swapped = true; + } + + // 实现一个延迟,以便在 UI 上展示排序的动画效果。 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + int getNextGap(int gap) { + // 根据当前间隔值计算下一个间隔值 + gap = (gap * 10) ~/ 13; + if (gap < 1) return 1; + return gap; + } + + ///鸽巢排序 + pigeonHole() async { + int min = numbers[0]; + int max = numbers[0]; + int range, i, j, index; + + // 找到数组中的最大值和最小值 + for (int a = 0; a < numbers.length; a++) { + if (numbers[a] > max) max = numbers[a]; + if (numbers[a] < min) min = numbers[a]; + } + + // 计算鸽巢的个数 + range = max - min + 1; + List p = List.generate(range, (i) => 0); + + // 将数字分配到各个鸽巢中 + for (i = 0; i < numbers.length; i++) { + p[numbers[i] - min]++; + } + + index = 0; + + // 将鸽巢中的数字取出,重新放回到数组中 + for (j = 0; j < range; j++) { + while (p[j]-- > 0) { + numbers[index++] = j + min; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///希尔排序 + shellSort() async { + //定义变量 gap 并初始化为数组长度的一半。每次循环完成后将 gap 减半直到等于 0。 + for (int gap = numbers.length ~/ 2; gap > 0; gap ~/= 2) { + //遍历每个子序列并进行插入排序。初始时从第一个子序列的第二个元素开始,即 i = gap,以 gap 为步长逐个遍历每个子序列。 + for (int i = gap; i < numbers.length; i += 1) { + //将当前遍历到的元素赋值给它 + int temp = numbers[i]; + //内部使用一个 for 循环来实现插入排序。 + //循环开始时定义变量 j 并将其初始化为当前遍历到的元素的下标。通过不断比较前后相隔 gap 的元素大小并交换位置,将当前元素插入到正确的位置。 + int j; + for (j = i; j >= gap && numbers[j - gap] > temp; j -= gap) { + numbers[j] = numbers[j - gap]; + } + numbers[j] = temp; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///选择排序 + selectionSort() async { + for (int i = 0; i < numbers.length; i++) { + for (int j = i + 1; j < numbers.length; j++) { + // 遍历未排序部分,内层循环控制变量 j + if (numbers[i] > numbers[j]) { + // 判断当前元素是否比后续元素小 + int temp = numbers[j]; + // 交换当前元素和后续较小的元素 + numbers[j] = numbers[i]; + numbers[i] = temp; + } + + await Future.delayed(getDuration(), () {}); + + streamController.add(numbers); + } + } + } + + ///循环排序 + cycleSort() async { + int writes = 0; + for (int cycleStart = 0; cycleStart <= numbers.length - 2; cycleStart++) { + int item = numbers[cycleStart]; + int pos = cycleStart; + + // 在未排序部分中寻找比当前元素小的元素个数 + for (int i = cycleStart + 1; i < numbers.length; i++) { + if (numbers[i] < item) pos++; + } + + // 如果当前元素已经在正确位置上,则跳过此次迭代 + if (pos == cycleStart) { + continue; + } + + // 将当前元素放置到正确的位置上,并记录写操作次数 + while (item == numbers[pos]) { + pos += 1; + } + if (pos != cycleStart) { + int temp = item; + item = numbers[pos]; + numbers[pos] = temp; + writes++; + } + + // 循环将位于当前位置的元素放置到正确的位置上 + while (pos != cycleStart) { + pos = cycleStart; + // 继续在未排序部分中寻找比当前元素小的元素个数 + for (int i = cycleStart + 1; i < numbers.length; i++) { + if (numbers[i] < item) pos += 1; + } + + // 将当前元素放置到正确的位置上,并记录写操作次数 + while (item == numbers[pos]) { + pos += 1; + } + if (item != numbers[pos]) { + int temp = item; + item = numbers[pos]; + numbers[pos] = temp; + writes++; + } + + // 添加延迟操作以展示排序过程 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///堆排序 + heapSort() async { + // 从最后一个非叶子节点开始,构建最大堆 + for (int i = numbers.length ~/ 2; i >= 0; i--) { + await heapify(numbers, numbers.length, i); + } + + // 依次取出最大堆的根节点(最大值),并进行堆化 + for (int i = numbers.length - 1; i >= 0; i--) { + int temp = numbers[0]; + numbers[0] = numbers[i]; + numbers[i] = temp; + await heapify(numbers, i, 0); + streamController.add(numbers); + } + } + + heapify(List arr, int n, int i) async { + int largest = i; + int l = 2 * i + 1; // 左子节点索引 + int r = 2 * i + 2; // 右子节点索引 + + // 如果左子节点存在并且大于父节点,则更新最大值索引 + if (l < n && arr[l] > arr[largest]) largest = l; + + // 如果右子节点存在并且大于父节点或左子节点,则更新最大值索引 + if (r < n && arr[r] > arr[largest]) largest = r; + + // 如果最大值索引不等于当前节点索引,则交换节点值,并递归进行堆化 + if (largest != i) { + int temp = numbers[i]; + numbers[i] = numbers[largest]; + numbers[largest] = temp; + heapify(arr, n, largest); + } + + await Future.delayed(getDuration()); // 延迟操作,用于可视化排序过程 + streamController.add(numbers); + } + + ///插入排序 + insertionSort() async { + for (int i = 1; i < numbers.length; i++) { + int temp = numbers[i]; // 将当前元素存储到临时变量 temp 中 + int j = i - 1; // j 表示已排序部分的最后一个元素的索引 + + // 在已排序部分从后往前查找,找到合适位置插入当前元素 + while (j >= 0 && temp < numbers[j]) { + numbers[j + 1] = numbers[j]; // 当前元素比已排序部分的元素小,将元素后移一位 + --j; // 向前遍历 + await Future.delayed(getDuration()); + streamController.add(numbers); // 更新排序结果 + } + + numbers[j + 1] = temp; // 插入当前元素到已排序部分的正确位置 + await Future.delayed(getDuration(), () {}); + streamController.add(numbers); // 更新排序结果 + } + } + + ///地精排序 (侏儒排序) + gnomeSort() async { + int index = 0; + + while (index < numbers.length) { + // 当 index 小于数组长度时执行循环 + if (index == 0) index++; + if (numbers[index] >= numbers[index - 1]) { + // 如果当前元素大于等于前面的元素,则将 index 加1 + index++; + } else { + // 否则,交换这两个元素,并将 index 减1(使得元素可以沉到正确位置) + int temp = numbers[index]; + numbers[index] = numbers[index - 1]; + numbers[index - 1] = temp; + index--; + } + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + return; + } + + ///奇偶排序(Odd-Even Sort) + oddEvenSort() async { + bool isSorted = false; + + while (!isSorted) { + // 当 isSorted 为 false 时执行循环 + isSorted = true; // 先假设数组已经排好序 + + for (int i = 1; i <= numbers.length - 2; i = i + 2) { + // 对奇数索引位置进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果当前元素大于后面的元素,则交换它们的值 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + isSorted = false; // 若发生了交换,则说明数组仍未完全排序,将 isSorted 设为 false + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + for (int i = 0; i <= numbers.length - 2; i = i + 2) { + // 对偶数索引位置进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果当前元素大于后面的元素,则交换它们的值 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + isSorted = false; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + return; + } + + ///快速排序 + quickSort(int leftIndex, int rightIndex) async { + // 定义一个名为 _partition 的异步函数,用于划分数组,并返回划分后的基准元素的索引位置 + Future _partition(int left, int right) async{ + // 选择中间位置的元素作为基准元素 + int p = (left + (right - left) / 2).toInt(); + + // 交换基准元素和最右边的元素 + var temp = numbers[p]; + numbers[p] = numbers[right]; + numbers[right] = temp; + await Future.delayed(getDuration()); + streamController.add(numbers); + + // 初始化游标 cursor + int cursor = left; + + // 遍历数组并根据基准元素将元素交换到左侧或右侧 + for (int i = left; i < right; i++) { + if (cf(numbers[i], numbers[right]) <= 0) { + // 如果当前元素小于等于基准元素,则交换它和游标位置的元素 + var temp = numbers[i]; + numbers[i] = numbers[cursor]; + numbers[cursor] = temp; + cursor++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + // 将基准元素放置在游标位置 + temp = numbers[right]; + numbers[right] = numbers[cursor]; + numbers[cursor] = temp; + + await Future.delayed(getDuration()); + streamController.add(numbers); + + return cursor; // 返回基准元素的索引位置 + } + + // 如果左索引小于右索引,则递归地对数组进行快速排序 + if (leftIndex < rightIndex) { + int p = await _partition(leftIndex, rightIndex); + + await quickSort(leftIndex, p - 1); // 对基准元素左侧的子数组进行快速排序 + + await quickSort(p + 1, rightIndex); // 对基准元素右侧的子数组进行快速排序 + } + } + + // 比较函数,用于判断两个元素的大小关系 + cf(int a, int b) { + if (a < b) { + return -1; // 若 a 小于 b,则返回 -1 + } else if (a > b) { + return 1; // 若 a 大于 b,则返回 1 + } else { + return 0; // 若 a 等于 b,则返回 0 + } + } + + ///归并排序 + mergeSort(int leftIndex, int rightIndex) async { + // 定义一个名为 merge 的异步函数,用于合并两个有序子数组 + Future merge(int leftIndex, int middleIndex, int rightIndex) async { + // 计算左侧子数组和右侧子数组的大小 + int leftSize = middleIndex - leftIndex + 1; + int rightSize = rightIndex - middleIndex; + + // 创建左侧子数组和右侧子数组 + List leftList = List.generate(leftSize, (index) => 0); + List rightList = List.generate(rightSize, (index) => 0); + + // 将原始数组中的元素分别复制到左侧子数组和右侧子数组中 + for (int i = 0; i < leftSize; i++) { + leftList[i] = numbers[leftIndex + i]; + } + for (int j = 0; j < rightSize; j++) { + rightList[j] = numbers[middleIndex + j + 1]; + } + + // 初始化游标和索引 + int i = 0, j = 0; + int k = leftIndex; + + // 比较左侧子数组和右侧子数组的元素,并按顺序将较小的元素放入原始数组中 + while (i < leftSize && j < rightSize) { + if (leftList[i] <= rightList[j]) { + numbers[k] = leftList[i]; + i++; + } else { + numbers[k] = rightList[j]; + j++; + } + + await Future.delayed(getDuration()); + streamController.add(numbers); + + k++; + } + + // 将左侧子数组或右侧子数组中剩余的元素放入原始数组中 + while (i < leftSize) { + numbers[k] = leftList[i]; + i++; + k++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + while (j < rightSize) { + numbers[k] = rightList[j]; + j++; + k++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + // 如果左索引小于右索引,则递归地对数组进行归并排序 + if (leftIndex < rightIndex) { + // 计算中间索引位置 + int middleIndex = (rightIndex + leftIndex) ~/ 2; + + // 分别对左侧子数组和右侧子数组进行归并排序 + await mergeSort(leftIndex, middleIndex); + await mergeSort(middleIndex + 1, rightIndex); + + await Future.delayed(getDuration()); + streamController.add(numbers); + + // 合并两个有序子数组 + await merge(leftIndex, middleIndex, rightIndex); + } + } + + checkAndResetIfSorted() async { + if (isSorted) { + reset(); + await Future.delayed(const Duration(milliseconds: 200)); + } + } + + sort() async { + setState(() { + isSorting = true; + }); + + await checkAndResetIfSorted(); + + Stopwatch stopwatch = Stopwatch()..start(); + + switch (currentSort) { + case "bubble": + await bubbleSort(); + break; + case "coctail": + await cocktailSort(); + break; + case "comb": + await combSort(); + break; + case "pigeonhole": + await pigeonHole(); + break; + case "shell": + await shellSort(); + break; + case "selection": + await selectionSort(); + break; + case "cycle": + await cycleSort(); + break; + case "heap": + await heapSort(); + break; + case "insertion": + await insertionSort(); + break; + case "gnome": + await gnomeSort(); + break; + case "oddeven": + await oddEvenSort(); + break; + case "quick": + await quickSort(0, sampleSize.toInt() - 1); + break; + case "merge": + await mergeSort(0, sampleSize.toInt() - 1); + break; + } + + stopwatch.stop(); + + print("Sorting completed in ${stopwatch.elapsed.inMilliseconds} ms."); + setState(() { + isSorting = false; + isSorted = true; + }); + } + + setSort(String type) { + setState(() { + currentSort = type; + }); + } + + @override + void initState() { + super.initState(); + // reset(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + sampleSize = MediaQuery.of(context).size.width / 2; + for (int i = 0; i < sampleSize; ++i) { + //随机往数组中填值 + numbers.add(Random().nextInt(500)); + } + setState(() {}); + } + + @override + void dispose() { + streamController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + "当前选择的是:${getTitle()}", + style: const TextStyle(fontSize: 14), + ), + actions: [ + PopupMenuButton( + initialValue: currentSort, + itemBuilder: (ctx) { + return const [ + PopupMenuItem( + value: 'bubble', + child: Text("Bubble Sort — 冒泡排序"), + ), + PopupMenuItem( + value: 'coctail', + child: Text("Coctail Sort — 鸡尾酒排序(双向冒泡排序)"), + ), + PopupMenuItem( + value: 'comb', + child: Text("Comb Sort — 梳排序"), + ), + PopupMenuItem( + value: 'pigeonhole', + child: Text("pigeonhole Sort — 鸽巢排序"), + ), + PopupMenuItem( + value: 'shell', + child: Text("shell Sort — 希尔排序"), + ), + PopupMenuItem( + value: 'selection', + child: Text("Selection Sort — 选择排序"), + ), + PopupMenuItem( + value: 'cycle', + child: Text("CycleSort — 循环排序"), + ), + PopupMenuItem( + value: 'heap', + child: Text("HeapSort — 堆排序"), + ), + PopupMenuItem( + value: 'insertion', + child: Text("InsertionSort — 插入排序"), + ), + PopupMenuItem( + value: 'gnome', + child: Text("GnomeSort — 地精排序 (侏儒排序)"), + ), + PopupMenuItem( + value: 'oddeven', + child: Text("OddEvenSort — 奇偶排序"), + ), + PopupMenuItem( + value: 'quick', + child: Text("QuickSort — 快速排序"), + ), + PopupMenuItem( + value: 'merge', + child: Text("MergeSort — 归并排序"), + ), + ]; + }, + onSelected: (String value) { + reset(); + setSort(value); + }, + ) + ], + ), + body: StreamBuilder( + initialData: numbers, + stream: streamController.stream, + builder: (context, snapshot) { + List numbers = snapshot.data as List; + int counter = 0; + return Row( + children: numbers.map((int num) { + counter++; + return CustomPaint( + painter: BarPainter( + width: MediaQuery.of(context).size.width / sampleSize, + value: num, + index: counter, + ), + ); + }).toList(), + ); + }, + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton( + onPressed: isSorting + ? null + : () { + reset(); + setSort(currentSort); + }, + child: const Text("重置")), + ElevatedButton( + onPressed: isSorting ? null : sort, child: const Text("开始排序")), + ElevatedButton( + onPressed: isSorting ? null : changeSpeed, + child: Text( + "${speed + 1}x", + style: const TextStyle(fontSize: 20), + ), + ), + ], + ), + ), + ); + } +} + +class BarPainter extends CustomPainter { + //宽度 + final double width; + + //高度(数组中对应的值) + final int value; + + //位置索引 + final int index; + + BarPainter({required this.width, required this.value, required this.index}); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint(); + if (value < 500 * .10) { + paint.color = Colors.blue.shade100; + } else if (value < 500 * .20) { + paint.color = Colors.blue.shade200; + } else if (value < 500 * .30) { + paint.color = Colors.blue.shade300; + } else if (value < 500 * .40) { + paint.color = Colors.blue.shade400; + } else if (value < 500 * .50) { + paint.color = Colors.blue.shade500; + } else if (value < 500 * .60) { + paint.color = Colors.blue.shade600; + } else if (value < 500 * .70) { + paint.color = Colors.blue.shade700; + } else if (value < 500 * .80) { + paint.color = Colors.blue.shade800; + } else if (value < 500 * .90) { + paint.color = Colors.blue.shade900; + } else { + paint.color = const Color(0xFF011E51); + } + + paint.strokeWidth = width; + paint.strokeCap = StrokeCap.round; + + canvas.drawLine( + Offset(index * width, 0), + Offset( + index * width, + value.ceilToDouble(), + ), + paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/v6_result_/pages/user/user_page.dart b/lib/v6_result_/pages/user/user_page.dart new file mode 100644 index 0000000..aba9710 --- /dev/null +++ b/lib/v6_result_/pages/user/user_page.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class UserPage extends StatelessWidget { + const UserPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body:Center(child: Text('UserPage'))); + } +} diff --git a/lib/v7/app.dart b/lib/v7/app.dart new file mode 100644 index 0000000..c9460c2 --- /dev/null +++ b/lib/v7/app.dart @@ -0,0 +1 @@ +export 'app/unit_app.dart'; \ No newline at end of file diff --git a/lib/v7/app/navigation/router/app_router_delegate.dart b/lib/v7/app/navigation/router/app_router_delegate.dart new file mode 100644 index 0000000..20a4f30 --- /dev/null +++ b/lib/v7/app/navigation/router/app_router_delegate.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +import 'iroute.dart'; +import 'route_history.dart'; + +const List kDestinationsPaths = [ + '/color', + '/counter', + '/user', + '/settings', +]; + +AppRouterDelegate router = AppRouterDelegate(); + +class AppRouterDelegate extends RouterDelegate with ChangeNotifier { + String _path = '/color'; + + String get path => _path; + + AppRouterDelegate() { + // keepAlivePath.add('/color'); + _histories.add(RouteHistory(path)); + } + + final List _histories = []; + final List _backHistories = []; + + List get histories => _histories.reversed.toList(); + + bool get hasHistory => _histories.length > 1; + + bool get hasBackHistory => _backHistories.isNotEmpty; + + /// 历史回退操作 + /// 将当前顶层移除,并加入 _backHistories 撤销列表 + /// 并转到前一路径 + void back() { + if (!hasHistory) return; + RouteHistory top = _histories.removeLast(); + _backHistories.add(top); + if (_histories.isNotEmpty) { + _path = _histories.last.path; + if (_histories.last.extra != null) { + _pathExtraMap[_path] = _histories.last.extra; + } + notifyListeners(); + } + } + + void toHistory(RouteHistory history) { + _path = history.path; + if (history.extra != null) { + _pathExtraMap[_path] = history.extra; + } + notifyListeners(); + } + + void closeHistory(int index) { + _histories.removeAt(index); + notifyListeners(); + } + + void clearHistory() { + _histories.clear(); + notifyListeners(); + } + + /// 撤销回退操作 + /// 取出回退列表的最后元素,跳转到该路径 + void revocation() { + RouteHistory target = _backHistories.removeLast(); + _path = target.path; + if (target.extra != null) { + _pathExtraMap[_path] = target.extra; + } + _histories.add(target); + notifyListeners(); + } + + int? get activeIndex { + if (path.startsWith('/color')) return 0; + if (path.startsWith('/counter')) return 1; + if (path.startsWith('/user')) return 2; + if (path.startsWith('/settings')) return 3; + return null; + } + + final Map> _completerMap = {}; + + Completer? completer; + + final Map _pathExtraMap = {}; + + final List keepAlivePath = []; + + FutureOr changePath( + String value, { + bool forResult = false, + Object? extra, + bool keepAlive = false, + bool recordHistory = true, + }) { + if (_path == value) null; + if (forResult) { + _completerMap[value] = Completer(); + } + if (keepAlive) { + if (keepAlivePath.contains(value)) { + keepAlivePath.remove(value); + } + keepAlivePath.add(value); + } + if (extra != null) { + _pathExtraMap[value] = extra; + } + + if (recordHistory) { + _addPathToHistory(value,extra); + } + + _path = value; + notifyListeners(); + if (forResult) { + return _completerMap[value]!.future; + } + } + + + void _addPathToHistory(String value, Object? extra) { + if (_histories.isNotEmpty && value == _histories.last.path) return; + _histories.add(RouteHistory( + value, + extra: _pathExtraMap[path], + )); + } + + @override + Widget build(BuildContext context) { + return Navigator( + onPopPage: _onPopPage, + pages: _buildPages(context, path), + ); + } + + List _buildPages(BuildContext context, String path) { + List pages = []; + List topPages = _buildPageByPathFromTree(context, path); + + if (keepAlivePath.isNotEmpty) { + for (String alivePath in keepAlivePath) { + if (alivePath != path) { + pages.addAll(_buildPageByPathFromTree(context, alivePath)); + } + } + + /// 去除和 topPages 中重复的界面 + pages.removeWhere( + (element) => topPages.map((e) => e.key).contains(element.key)); + } + + pages.addAll(topPages); + return pages; + } + + List _buildPageByPathFromTree(BuildContext context, String path) { + List result = []; + List iRoutes = root.find(path); + if (iRoutes.isNotEmpty) { + for (int i = 0; i < iRoutes.length; i++) { + IRoute iroute = iRoutes[i]; + String path = iroute.path; + Object? extra = _pathExtraMap[path]; + bool keepAlive = keepAlivePath.contains(path); + bool forResult = _completerMap.containsKey(path); + Page? page = iroute.builder?.call( + context, + IRouteData( + uri: Uri.parse(path), + extra: extra, + keepAlive: keepAlive, + forResult: forResult, + ), + ); + if (page != null) { + result.add(page); + } + } + } + return result; + } + + @override + Future popRoute() async { + print('=======popRoute========='); + return true; + } + + bool _onPopPage(Route route, result) { + if (_completerMap.containsKey(path)) { + _completerMap[path]?.complete(result); + _completerMap.remove(path); + } + + changePath(backPath(path),recordHistory: false); + return route.didPop(result); + } + + String backPath(String path) { + Uri uri = Uri.parse(path); + if (uri.pathSegments.length == 1) return path; + List parts = List.of(uri.pathSegments)..removeLast(); + return '/${parts.join('/')}'; + } + + @override + Future setNewRoutePath(configuration) async {} +} diff --git a/lib/v7/app/navigation/router/iroute.dart b/lib/v7/app/navigation/router/iroute.dart new file mode 100644 index 0000000..5c417ae --- /dev/null +++ b/lib/v7/app/navigation/router/iroute.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../../../pages/color/color_add_page.dart'; +import '../../../pages/color/color_detail_page.dart'; +import '../../../pages/color/color_page.dart'; +import '../../../pages/counter/counter_page.dart'; +import '../../../pages/user/user_page.dart'; +import '../../../pages/settings/settings_page.dart'; +import '../transition/fade_transition_page.dart'; + +class IRoute { + final String path; + final IRoutePageBuilder? builder; + final List children; + + const IRoute({ + required this.path, + this.children = const [], + this.builder, + }); + + @override + String toString() { + return 'IRoute{path: $path, children: $children}'; + } + + IRoute? match(String path) { + return matchRoute(this, path); + } + + List find(String input){ + String fixInput = input.substring(1); + List nodes = findNodes(this,fixInput,0,'/',[]); + if(nodes.isNotEmpty&&nodes.last.path!=input){ + return []; + } + return nodes; + } + + List findNodes(IRoute node,String input,int deep,String prefix,List result){ + String separator = '/'; + + List parts = input.split(separator); + if(deep>parts.length-1){ + return result; + } + String target = parts[deep]; + if(node.children.isNotEmpty){ + List nodes = node.children.where((e) => e.path==prefix+target).toList(); + bool match = nodes.isNotEmpty; + if(match){ + IRoute matched = nodes.first; + result.add(matched); + String nextPrefix = '${matched.path}$separator'; + findNodes(matched, input, ++deep,nextPrefix,result); + } + }else{ + return result; + } + return result; + } + + // List findNodes(IRoute node,String input,int deep,String prefix,List result){ + // String separator = '/'; + // List parts = input.split(separator); + // if(deep>parts.length-1){ + // return result; + // } + // String target = parts[deep]; + // if(node.children.isNotEmpty){ + // List nodes = node.children.where((e) => e.path==prefix+target).toList(); + // bool match = nodes.isNotEmpty; + // if(match){ + // IRoute matched = nodes.first; + // result.add(matched); + // String nextPrefix = '${matched.path}$separator'; + // findNodes(matched, input, ++deep,nextPrefix,result); + // } + // }else{ + // return result; + // } + // return result; + // } + + IRoute? matchRoute(IRoute route, String path) { + if (route.path == path) { + return route; + } else { + if (route.children.isNotEmpty) { + for (int i = 0; i < route.children.length; i++) { + IRoute current = route.children[i]; + IRoute? target = matchRoute(current, path); + if (target != null) { + return target; + } + } + } else { + return null; + } + } + return null; + } +} + +typedef IRoutePageBuilder = Page? Function( + BuildContext context, IRouteData data); + +class IRouteData { + final Object? extra; + final bool forResult; + final Uri uri; + final bool keepAlive; + + IRouteData({ + this.extra, + required this.uri, + this.forResult = false, + this.keepAlive = false, + }); +} + +IRoute root = IRoute(path: 'root', children: kDestinationsIRoutes); + +List kDestinationsIRoutes = [ + IRoute( + path: '/color', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/color'), + child: ColorPage(), + ); + }, + children: [ + IRoute( + path: '/color/detail', + builder: (ctx, data) { + final Map queryParams = data.uri.queryParameters; + String? selectedColor = queryParams['color']; + Color color = Colors.black; + if (selectedColor != null) { + color = Color(int.parse(selectedColor, radix: 16)); + } else if (data.extra is Color) { + color = data.extra as Color; + } + return FadeTransitionPage( + key: const ValueKey('/color/detail'), + child: ColorDetailPage(color: color), + ); + }, + ), + IRoute( + path: '/color/add', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/color/add'), + child: ColorAddPage(), + ); + }), + ], + ), + IRoute( + path: '/counter', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/counter'), + child: CounterPage(), + ); + }), + IRoute( + path: '/user', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/user'), + child: UserPage(), + ); + }), + IRoute( + path: '/settings', + builder: (ctx, data) { + return const FadeTransitionPage( + key: ValueKey('/settings'), + child: SettingPage(), + ); + }), +]; diff --git a/lib/v7/app/navigation/router/route_history.dart b/lib/v7/app/navigation/router/route_history.dart new file mode 100644 index 0000000..02b5430 --- /dev/null +++ b/lib/v7/app/navigation/router/route_history.dart @@ -0,0 +1,6 @@ +class RouteHistory{ + final String path; + final Object? extra; + + RouteHistory(this.path, { this.extra}); +} \ No newline at end of file diff --git a/lib/v7/app/navigation/router/route_history_manager.dart b/lib/v7/app/navigation/router/route_history_manager.dart new file mode 100644 index 0000000..2709fea --- /dev/null +++ b/lib/v7/app/navigation/router/route_history_manager.dart @@ -0,0 +1,12 @@ +// import 'route_history.dart'; +// +// class RouteHistoryManager{ +// final List _histories = []; +// final List _backHistories = []; +// +// List get histories => _histories.reversed.toList(); +// +// bool get hasHistory => _histories.length > 1; +// +// bool get hasBackHistory => _backHistories.isNotEmpty; +// } \ No newline at end of file diff --git a/lib/v7/app/navigation/transition/fade_transition_page.dart b/lib/v7/app/navigation/transition/fade_transition_page.dart new file mode 100644 index 0000000..552171b --- /dev/null +++ b/lib/v7/app/navigation/transition/fade_transition_page.dart @@ -0,0 +1,53 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class FadeTransitionPage extends Page { + final Widget child; + final Duration duration; + + const FadeTransitionPage({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 300), + }); + + @override + Route createRoute(BuildContext context) => + PageBasedFadeTransitionRoute(this); +} + +class PageBasedFadeTransitionRoute extends PageRoute { + final FadeTransitionPage _page; + + PageBasedFadeTransitionRoute(this._page) : super(settings: _page); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => _page.duration; + + @override + bool get maintainState => true; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + var curveTween = CurveTween(curve: Curves.easeIn); + return FadeTransition( + opacity: animation.drive(curveTween), + child: (settings as FadeTransitionPage).child, + ); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) => + child; +} diff --git a/lib/v7/app/navigation/transition/no_transition_page.dart b/lib/v7/app/navigation/transition/no_transition_page.dart new file mode 100644 index 0000000..291910b --- /dev/null +++ b/lib/v7/app/navigation/transition/no_transition_page.dart @@ -0,0 +1,47 @@ +// Copyright 2021, the Flutter project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class NoTransitionPage extends Page { + final Widget child; + + const NoTransitionPage({ + super.key, + required this.child, + }); + + @override + Route createRoute(BuildContext context) => NoTransitionRoute(this); +} + +class NoTransitionRoute extends PageRoute { + + final NoTransitionPage _page; + + NoTransitionRoute(this._page) : super(settings: _page); + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 0); + + @override + bool get maintainState => true; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + return (settings as NoTransitionPage).child; + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) => + child; +} diff --git a/lib/v7/app/navigation/views/app_navigation.dart b/lib/v7/app/navigation/views/app_navigation.dart new file mode 100644 index 0000000..5abca82 --- /dev/null +++ b/lib/v7/app/navigation/views/app_navigation.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import '../router/app_router_delegate.dart'; +import 'app_navigation_rail.dart'; +import 'app_top_bar/app_top_bar.dart'; + +class AppNavigation extends StatelessWidget { + const AppNavigation({super.key}); + + @override + Widget build(BuildContext context) { + double px1 = 1/View.of(context).devicePixelRatio; + return Scaffold( + body: Row( + children: [ + const AppNavigationRail(), + Expanded( + child: Column( + children: [ + const AppTopBar(), + Divider(height: px1,), + Expanded( + child: Router( + routerDelegate: router, + backButtonDispatcher: RootBackButtonDispatcher(), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/v7/app/navigation/views/app_navigation_rail.dart b/lib/v7/app/navigation/views/app_navigation_rail.dart new file mode 100644 index 0000000..759c50f --- /dev/null +++ b/lib/v7/app/navigation/views/app_navigation_rail.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../router/app_router_delegate.dart'; + +class AppNavigationRail extends StatefulWidget { + const AppNavigationRail({super.key}); + + @override + State createState() => _AppNavigationRailState(); +} + +class _AppNavigationRailState extends State { + + final List deskNavBarMenus = const [ + MenuMeta(label: '颜色板', icon: Icons.color_lens_outlined), + MenuMeta(label: '计数器', icon: Icons.add_chart), + MenuMeta(label: '我的', icon: Icons.person), + MenuMeta(label: '设置', icon: Icons.settings), + ]; + + @override + void initState() { + super.initState(); + router.addListener(_onRouterChange); + } + + @override + void dispose() { + router.removeListener(_onRouterChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DragToMoveWrap( + child: TolyNavigationRail( + items: deskNavBarMenus, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 18.0), + child: FlutterLogo(), + ), + tail: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Text('V0.0.6',style: TextStyle(color: Colors.white,fontSize: 12),), + ), + backgroundColor: const Color(0xff3975c6), + onDestinationSelected: _onDestinationSelected, + selectedIndex: router.activeIndex, + ), + ); + + } + + void _onDestinationSelected(int index) { + if(index==1){ + router.changePath(kDestinationsPaths[index],keepAlive: true); + }else{ + router.changePath(kDestinationsPaths[index]); + } + } + + void _onRouterChange() { + setState(() {}); + } +} diff --git a/lib/v7/app/navigation/views/app_top_bar/app_router_editor.dart b/lib/v7/app/navigation/views/app_top_bar/app_router_editor.dart new file mode 100644 index 0000000..40516a7 --- /dev/null +++ b/lib/v7/app/navigation/views/app_top_bar/app_router_editor.dart @@ -0,0 +1,64 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:iroute/components/toly_ui/button/hover_icon_button.dart'; +import '../../router/app_router_delegate.dart'; + +class AppRouterEditor extends StatefulWidget { + final ValueChanged? onSubmit; + const AppRouterEditor({super.key, this.onSubmit}); + + @override + State createState() => _AppRouterEditorState(); +} + +class _AppRouterEditorState extends State { + + final TextEditingController _controller = TextEditingController(); + + + @override + void initState() { + super.initState(); + _onRouteChange(); + router.addListener(_onRouteChange); + } + + void _onRouteChange() { + _controller.text=router.path; + } + + @override + void dispose() { + _controller.dispose(); + router.removeListener(_onRouteChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.centerRight, + children: [ + SizedBox( + child: CupertinoTextField( + controller: _controller, + style: TextStyle(fontSize: 14), + padding: EdgeInsets.only(left:12,top: 6,bottom: 6,right: 32), + placeholder: '输入路由地址导航', + onSubmitted: widget.onSubmit, + decoration: BoxDecoration(color: Color(0xffF1F2F3),borderRadius: BorderRadius.circular(6)), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: HoverIconButton( + icon: Icons.directions_outlined, + defaultColor: Color(0xff68696B), + onPressed:()=>widget.onSubmit?.call(_controller.text), + size: 20 + ), + ) + ], + ); + } +} diff --git a/lib/v7/app/navigation/views/app_top_bar/app_top_bar.dart b/lib/v7/app/navigation/views/app_top_bar/app_top_bar.dart new file mode 100644 index 0000000..fabe4d4 --- /dev/null +++ b/lib/v7/app/navigation/views/app_top_bar/app_top_bar.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../../router/app_router_delegate.dart'; +import 'app_router_editor.dart'; +import 'history_view_icon.dart'; +import 'route_history_button.dart'; + +class AppTopBar extends StatelessWidget { + const AppTopBar({super.key}); + + @override + Widget build(BuildContext context) { + return DragToMoveWrap( + child: Container( + alignment: Alignment.center, + height: 46, + child: Row( + children: [ + const SizedBox(width: 16), + const RouterIndicator(), + Expanded( + child: Row(children: [ + const Spacer(), + RouteHistoryButton(), + const SizedBox(width: 12,), + SizedBox( + width: 250, + child: AppRouterEditor( + onSubmit: (path) => router.changePath(path), + )), + const SizedBox(width: 12,), + HistoryViewIcon(), + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: VerticalDivider( + width: 32, + ), + ) + ])), + const WindowButtons() + ], + ), + ), + ); + } +} + +class RouterIndicator extends StatefulWidget { + const RouterIndicator({super.key}); + + @override + State createState() => _RouterIndicatorState(); +} + +Map kRouteLabelMap = { + '/color': '颜色板', + '/color/add': '添加颜色', + '/color/detail': '颜色详情', + '/counter': '计数器', + '/user': '我的', + '/settings': '系统设置', +}; + +class _RouterIndicatorState extends State { + @override + void initState() { + super.initState(); + router.addListener(_onRouterChange); + } + + @override + void dispose() { + router.removeListener(_onRouterChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TolyBreadcrumb( + items: pathToBreadcrumbItems(router.path), + onTapItem: (item) { + if (item.to != null) { + router.changePath(item.to!); + } + }, + ); + } + + void _onRouterChange() { + setState(() {}); + } + + List pathToBreadcrumbItems(String path) { + Uri uri = Uri.parse(path); + List result = []; + String to = ''; + + String distPath = ''; + for (String segment in uri.pathSegments) { + distPath += '/$segment'; + } + + for (String segment in uri.pathSegments) { + to += '/$segment'; + String label = kRouteLabelMap[to] ?? '未知路由'; + result.add(BreadcrumbItem(to: to, label: label, active: to == distPath)); + } + return result; + } +} diff --git a/lib/v7/app/navigation/views/app_top_bar/history_view_icon.dart b/lib/v7/app/navigation/views/app_top_bar/history_view_icon.dart new file mode 100644 index 0000000..e7dd0a5 --- /dev/null +++ b/lib/v7/app/navigation/views/app_top_bar/history_view_icon.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/components.dart'; +import '../../router/app_router_delegate.dart'; +import '../../router/route_history.dart'; +import 'app_top_bar.dart'; + +class HistoryViewIcon extends StatelessWidget{ + const HistoryViewIcon({super.key}); + + @override + Widget build(BuildContext context) { + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: PopPanel( + offset: const Offset(0, 10), + panel: SizedBox( + height: 350, + child: Column( + children: [ + _buildTopBar(), + const Expanded( + child:HistoryPanel(), + ), + ], + ), + ), + child: const Icon( + Icons.history, + size: 20, + ), + ), + ); + } + + Widget _buildTopBar() { + return Container( + decoration: BoxDecoration( + color: const Color(0xffFAFAFC), + borderRadius: BorderRadius.circular(6), + ), + padding: + const EdgeInsets.only(top: 10, left: 12, right: 12, bottom: 8), + child: Row( + children: [ + const Text( + '浏览历史', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const Spacer(), + TextButton(onPressed: router.clearHistory, child: const Text('清空历史')) + ], + )); + } +} + +class HistoryItem extends StatefulWidget { + final RouteHistory history; + final VoidCallback onPressed; + final VoidCallback onDelete; + + const HistoryItem({super.key, required this.history, required this.onPressed, required this.onDelete}); + + @override + State createState() => _HistoryItemState(); +} + +class _HistoryItemState extends State { + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.history.path), + const SizedBox( + height: 2, + ), + Text(kRouteLabelMap[widget.history.path]!), + ], + )), + GestureDetector( + onTap: widget.onDelete, + child: const Icon( + Icons.close, + size: 18, + color: Color(0xff8E92A9), + ), + ), + ], + ), + ), + ); + } +} + +class HistoryPanel extends StatefulWidget { + const HistoryPanel({super.key}); + + @override + State createState() => _HistoryPanelState(); +} + +class _HistoryPanelState extends State { + + @override + void initState() { + super.initState(); + router.addListener(_onChange); + } + + @override + void dispose() { + router.removeListener(_onChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if(router.histories.isEmpty){ + return const Center( + child: Text( + '暂无浏览历史记录', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + itemExtent: 46, + itemCount: router.histories.length, + itemBuilder: (_, index) => + HistoryItem( + onDelete: (){ + int fixIndex = router.histories.length - 1 - index; + router.closeHistory(fixIndex); + }, + onPressed: (){ + router.toHistory(router.histories[index]); + Navigator.of(context).pop(); + }, + history: router.histories[index]), + ); + } + + void _onChange() { + setState(() {}); + } +} diff --git a/lib/v7/app/navigation/views/app_top_bar/route_history_button.dart b/lib/v7/app/navigation/views/app_top_bar/route_history_button.dart new file mode 100644 index 0000000..e02439f --- /dev/null +++ b/lib/v7/app/navigation/views/app_top_bar/route_history_button.dart @@ -0,0 +1,58 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:iroute/components/toly_ui/button/hover_icon_button.dart'; +import '../../router/app_router_delegate.dart'; + +class RouteHistoryButton extends StatefulWidget { + const RouteHistoryButton({super.key}); + + @override + State createState() => _RouteHistoryButtonState(); +} + +class _RouteHistoryButtonState extends State { + @override + void initState() { + super.initState(); + router.addListener(_onChange); + } + + @override + void dispose() { + router.removeListener(_onChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool hasHistory = router.hasHistory; + bool hasBackHistory = router.hasBackHistory; + Color activeColor = const Color(0xff9195AC); + Color inActiveColor = const Color(0xffC7CAD5); + Color historyColor = hasHistory?activeColor:inActiveColor; + Color backHistoryColor = hasBackHistory?activeColor:inActiveColor; + return Wrap( + children: [ + HoverIconButton( + size: 20, + hoverColor: historyColor, + defaultColor: historyColor, + icon: CupertinoIcons.arrow_left_circle, + onPressed: hasHistory?router.back:null, + ), + const SizedBox(width: 8,), + HoverIconButton( + size: 20, + hoverColor: backHistoryColor, + defaultColor: backHistoryColor, + icon: CupertinoIcons.arrow_right_circle, + onPressed: hasBackHistory?router.revocation:null, + ), + ], + ); + } + + void _onChange() { + setState(() {}); + } +} diff --git a/lib/v7/app/unit_app.dart b/lib/v7/app/unit_app.dart new file mode 100644 index 0000000..1a21114 --- /dev/null +++ b/lib/v7/app/unit_app.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'navigation/router/app_router_delegate.dart'; +import 'navigation/views/app_navigation.dart'; +import 'navigation/views/app_navigation_rail.dart'; + +class UnitApp extends StatelessWidget { + const UnitApp({super.key}); + + @override + Widget build(BuildContext context) { + + return MaterialApp( + theme: ThemeData( + fontFamily: "宋体", + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme( + elevation: 0, + iconTheme: IconThemeData(color: Colors.black), + titleTextStyle: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.bold, + ))), + debugShowCheckedModeBanner: false, + home: AppNavigation() + ); + } +} + + diff --git a/lib/v7/pages/color/color_add_page.dart b/lib/v7/pages/color/color_add_page.dart new file mode 100644 index 0000000..48e6dc6 --- /dev/null +++ b/lib/v7/pages/color/color_add_page.dart @@ -0,0 +1,99 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; + +class ColorAddPage extends StatefulWidget { + const ColorAddPage({super.key}); + + @override + State createState() => _ColorAddPageState(); +} + +class _ColorAddPageState extends State { + late Color _color; + + @override + void initState() { + super.initState(); + _color = randomColor; + } + + @override + Widget build(BuildContext context) { + String text = '# ${_color.value.toRadixString(16)}'; + return Scaffold( + bottomNavigationBar: Container( + margin: EdgeInsets.only(right:20,bottom: 20), + // color: Colors.redAccent, + child: Row( + textDirection:TextDirection.rtl, + children: [ + ElevatedButton(onPressed: (){ + Navigator.of(context).pop(_color); + + }, child: Text('添加')), + SizedBox(width: 12,), + OutlinedButton(onPressed: (){ + Navigator.of(context).pop(); + }, child: Text('取消')), + ], + ), + ), + body: Column( + // mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0,vertical: 16), + child: Row( + children: [ + Expanded(child: Text(text,style: TextStyle(color: _color,fontSize: 24,letterSpacing: 4),)), + Container( + margin: EdgeInsets.only(left: 10), + width: 40, + height: 40, + child: Icon( + Icons.sell_outlined, + color: Colors.white, + ), + decoration: BoxDecoration( + color: _color, + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ), + ColorPicker( + colorPickerWidth:200, + // enableAlpha: false, + displayThumbColor:true, + pickerColor: _color, + paletteType: PaletteType.hueWheel, + onColorChanged: changeColor, + + ), + ], + ), + ); + } + + Random _random = Random(); + + Color get randomColor { + return Color.fromARGB( + 255, + _random.nextInt(256), + _random.nextInt(256), + _random.nextInt(256), + ); + } + + + void changeColor(Color value) { + _color = value; + setState(() { + + }); + } +} diff --git a/lib/v7/pages/color/color_detail_page.dart b/lib/v7/pages/color/color_detail_page.dart new file mode 100644 index 0000000..7dfed86 --- /dev/null +++ b/lib/v7/pages/color/color_detail_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ColorDetailPage extends StatelessWidget { + final Color color; + const ColorDetailPage({super.key, required this.color}); + + @override + Widget build(BuildContext context) { + + const TextStyle style = TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white + ); + String text = '# ${color.value.toRadixString(16)}'; + return Scaffold( + body: Container( + alignment: Alignment.center, + color: color, + child: Text(text ,style: style,), + ), + ); + } +} diff --git a/lib/v7/pages/color/color_page.dart b/lib/v7/pages/color/color_page.dart new file mode 100644 index 0000000..e5e6981 --- /dev/null +++ b/lib/v7/pages/color/color_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:iroute/components/project/colors_panel.dart'; +import '../../app/navigation/router/app_router_delegate.dart'; + +class ColorPage extends StatefulWidget { + const ColorPage({super.key}); + + @override + State createState() => _ColorPageState(); +} + +class _ColorPageState extends State { + final List _colors = [ + Colors.red, Colors.black, Colors.blue, Colors.green, Colors.orange, + Colors.pink, Colors.purple, Colors.indigo, Colors.amber, Colors.cyan, + Colors.redAccent, Colors.grey, Colors.blueAccent, Colors.greenAccent, Colors.orangeAccent, + Colors.pinkAccent, Colors.purpleAccent, Colors.indigoAccent, Colors.amberAccent, Colors.cyanAccent, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + // appBar: AppBar(title:const Text('颜色主页')), + floatingActionButton: FloatingActionButton( + onPressed: _toAddPage, + child: const Icon(Icons.add), + ), + body: Align( + alignment: Alignment.topCenter, + child: ColorsPanel( + colors: _colors, + onSelect: _selectColor, + ), + ), + ); + } + + void _selectColor(Color color){ + // String value = color.value.toRadixString(16); + // router.path = '/color/detail?color=$value'; + router.changePath('/color/detail',extra: color); + + } + + void _toAddPage() async { + Color? color = await router.changePath('/color/add',forResult: true); + if (color != null) { + setState(() { + _colors.add(color); + }); + } + } +} \ No newline at end of file diff --git a/lib/v7/pages/counter/counter_page.dart b/lib/v7/pages/counter/counter_page.dart new file mode 100644 index 0000000..b5b2e17 --- /dev/null +++ b/lib/v7/pages/counter/counter_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class CounterPage extends StatefulWidget { + const CounterPage({super.key}); + + @override + State createState() => _CounterPageState(); +} + +class _CounterPageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + ); + } +} \ No newline at end of file diff --git a/lib/v7/pages/empty/empty_page.dart b/lib/v7/pages/empty/empty_page.dart new file mode 100644 index 0000000..b05f56f --- /dev/null +++ b/lib/v7/pages/empty/empty_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class EmptyPage extends StatelessWidget { + const EmptyPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // appBar: AppBar( + // title: Text('界面走丢了'), + // ), + body: Scaffold( + body: Center( + child: Wrap( + spacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, + direction: Axis.vertical, + children: [ + Icon(Icons.nearby_error,size: 64, color: Colors.grey), + Text( + '404 界面丢失', + style: TextStyle(fontSize: 24, color: Colors.grey), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/v7/pages/settings/settings_page.dart b/lib/v7/pages/settings/settings_page.dart new file mode 100644 index 0000000..0b53503 --- /dev/null +++ b/lib/v7/pages/settings/settings_page.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class SettingPage extends StatelessWidget { + const SettingPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body:Center(child: Text('SettingPage'))); + } +} diff --git a/lib/v7/pages/sort/sort_page.dart b/lib/v7/pages/sort/sort_page.dart new file mode 100644 index 0000000..d440071 --- /dev/null +++ b/lib/v7/pages/sort/sort_page.dart @@ -0,0 +1,859 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class SortPage extends StatefulWidget { + const SortPage({Key? key}) : super(key: key); + + @override + State createState() => _SortPageState(); +} + +class _SortPageState extends State { + //存放随机数组 + List numbers = []; + + //订阅流 + StreamController> streamController = StreamController(); + String currentSort = 'bubble'; + + //柱子的数量 -> 生成排序数组的长度 + double sampleSize = 0; + + //是否排序 + bool isSorted = false; + + //是否在排序中 + bool isSorting = false; + + //排序动画更新的速度 + int speed = 0; + + static int duration = 1500; + + String getTitle() { + switch (currentSort) { + case "bubble": + return "Bubble Sort"; + case "coctail": + return "Coctail Sort"; + case "comb": + return "Comb Sort"; + case "pigeonhole": + return "Pigeonhole Sort"; + case "shell": + return "Shell Sort"; + case "selection": + return "Selection Sort"; + case "cycle": + return "Cycle Sort"; + case "heap": + return "Heap Sort"; + case "insertion": + return "Insertion Sort"; + case "gnome": + return "Gnome Sort"; + case "oddeven": + return "OddEven Sort"; + case "quick": + return "Quick Sort"; + case "merge": + return "Merge Sort"; + } + return ""; + } + + reset() { + isSorted = false; + numbers = []; + for (int i = 0; i < sampleSize; ++i) { + numbers.add(Random().nextInt(500)); + } + streamController.add(numbers); + } + + Duration getDuration() { + return Duration(microseconds: duration); + } + + ///动画时间 + changeSpeed() { + if (speed >= 3) { + speed = 0; + duration = 1500; + } else { + speed++; + duration = duration ~/ 2; + } + setState(() {}); + } + + ///冒泡排序 + bubbleSort() async { + //控制需要进行排序的次数。每一轮循环都会确定一个数字的最终位置。 + for (int i = 0; i < numbers.length; ++i) { + //遍历当前未排序的元素,通过相邻的元素比较并交换位置来完成排序。 + for (int j = 0; j < numbers.length - i - 1; ++j) { + //如果 _numbers[j] 大于 _numbers[j + 1],则交换它们的位置,确保较大的元素移到右边。 + if (numbers[j] > numbers[j + 1]) { + int temp = numbers[j]; + numbers[j] = numbers[j + 1]; + numbers[j + 1] = temp; + } + //实现一个延迟,以便在ui上展示排序的动画效果 + await Future.delayed(getDuration(), () {}); + streamController.add(numbers); + } + } + } + + ///鸡尾酒排序(双向冒泡排序) + cocktailSort() async { + bool swapped = true; // 表示是否进行了交换 + int start = 0; // 当前未排序部分的起始位置 + int end = numbers.length; // 当前未排序部分的结束位置 + + // 开始排序循环,只有当没有进行交换时才会退出循环 + while (swapped == true) { + swapped = false; + + // 从左往右遍历需要排序的部分 + for (int i = start; i < end - 1; ++i) { + // 对每两个相邻元素进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果前面的元素大于后面的元素,则交换它们的位置 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + swapped = true; // 进行了交换 + } + + // 实现动画效果,延迟一段时间后更新数组状态 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + // 如果没有进行交换,则说明已经排好序,退出循环 + if (swapped == false) break; + // 重设为false,准备进行下一轮排序 + swapped = false; + // 将end设置为上一轮排序的最后一个元素的位置 + end = end - 1; + + // 从右往左遍历需要排序的部分 + for (int i = end - 1; i >= start; i--) { + // 对每两个相邻元素进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果前面的元素大于后面的元素,则交换它们的位置 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + swapped = true; // 进行了交换 + } + + // 实现动画效果,延迟一段时间后更新数组状态 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + // 将start向右移一位,准备下一轮排序 + start = start + 1; + } + } + + ///梳排序(Comb Sort) + combSort() async { + int gap = numbers.length; + + bool swapped = true; + + // 当间隔不为1或存在交换时执行循环 + while (gap != 1 || swapped == true) { + // 通过缩小间隔来逐步将元素归位 + gap = getNextGap(gap); + swapped = false; + for (int i = 0; i < numbers.length - gap; i++) { + // 如果当前元素大于间隔位置上的元素,则交换它们的位置 + if (numbers[i] > numbers[i + gap]) { + int temp = numbers[i]; + numbers[i] = numbers[i + gap]; + numbers[i + gap] = temp; + swapped = true; + } + + // 实现一个延迟,以便在 UI 上展示排序的动画效果。 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + int getNextGap(int gap) { + // 根据当前间隔值计算下一个间隔值 + gap = (gap * 10) ~/ 13; + if (gap < 1) return 1; + return gap; + } + + ///鸽巢排序 + pigeonHole() async { + int min = numbers[0]; + int max = numbers[0]; + int range, i, j, index; + + // 找到数组中的最大值和最小值 + for (int a = 0; a < numbers.length; a++) { + if (numbers[a] > max) max = numbers[a]; + if (numbers[a] < min) min = numbers[a]; + } + + // 计算鸽巢的个数 + range = max - min + 1; + List p = List.generate(range, (i) => 0); + + // 将数字分配到各个鸽巢中 + for (i = 0; i < numbers.length; i++) { + p[numbers[i] - min]++; + } + + index = 0; + + // 将鸽巢中的数字取出,重新放回到数组中 + for (j = 0; j < range; j++) { + while (p[j]-- > 0) { + numbers[index++] = j + min; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///希尔排序 + shellSort() async { + //定义变量 gap 并初始化为数组长度的一半。每次循环完成后将 gap 减半直到等于 0。 + for (int gap = numbers.length ~/ 2; gap > 0; gap ~/= 2) { + //遍历每个子序列并进行插入排序。初始时从第一个子序列的第二个元素开始,即 i = gap,以 gap 为步长逐个遍历每个子序列。 + for (int i = gap; i < numbers.length; i += 1) { + //将当前遍历到的元素赋值给它 + int temp = numbers[i]; + //内部使用一个 for 循环来实现插入排序。 + //循环开始时定义变量 j 并将其初始化为当前遍历到的元素的下标。通过不断比较前后相隔 gap 的元素大小并交换位置,将当前元素插入到正确的位置。 + int j; + for (j = i; j >= gap && numbers[j - gap] > temp; j -= gap) { + numbers[j] = numbers[j - gap]; + } + numbers[j] = temp; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///选择排序 + selectionSort() async { + for (int i = 0; i < numbers.length; i++) { + for (int j = i + 1; j < numbers.length; j++) { + // 遍历未排序部分,内层循环控制变量 j + if (numbers[i] > numbers[j]) { + // 判断当前元素是否比后续元素小 + int temp = numbers[j]; + // 交换当前元素和后续较小的元素 + numbers[j] = numbers[i]; + numbers[i] = temp; + } + + await Future.delayed(getDuration(), () {}); + + streamController.add(numbers); + } + } + } + + ///循环排序 + cycleSort() async { + int writes = 0; + for (int cycleStart = 0; cycleStart <= numbers.length - 2; cycleStart++) { + int item = numbers[cycleStart]; + int pos = cycleStart; + + // 在未排序部分中寻找比当前元素小的元素个数 + for (int i = cycleStart + 1; i < numbers.length; i++) { + if (numbers[i] < item) pos++; + } + + // 如果当前元素已经在正确位置上,则跳过此次迭代 + if (pos == cycleStart) { + continue; + } + + // 将当前元素放置到正确的位置上,并记录写操作次数 + while (item == numbers[pos]) { + pos += 1; + } + if (pos != cycleStart) { + int temp = item; + item = numbers[pos]; + numbers[pos] = temp; + writes++; + } + + // 循环将位于当前位置的元素放置到正确的位置上 + while (pos != cycleStart) { + pos = cycleStart; + // 继续在未排序部分中寻找比当前元素小的元素个数 + for (int i = cycleStart + 1; i < numbers.length; i++) { + if (numbers[i] < item) pos += 1; + } + + // 将当前元素放置到正确的位置上,并记录写操作次数 + while (item == numbers[pos]) { + pos += 1; + } + if (item != numbers[pos]) { + int temp = item; + item = numbers[pos]; + numbers[pos] = temp; + writes++; + } + + // 添加延迟操作以展示排序过程 + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + ///堆排序 + heapSort() async { + // 从最后一个非叶子节点开始,构建最大堆 + for (int i = numbers.length ~/ 2; i >= 0; i--) { + await heapify(numbers, numbers.length, i); + } + + // 依次取出最大堆的根节点(最大值),并进行堆化 + for (int i = numbers.length - 1; i >= 0; i--) { + int temp = numbers[0]; + numbers[0] = numbers[i]; + numbers[i] = temp; + await heapify(numbers, i, 0); + streamController.add(numbers); + } + } + + heapify(List arr, int n, int i) async { + int largest = i; + int l = 2 * i + 1; // 左子节点索引 + int r = 2 * i + 2; // 右子节点索引 + + // 如果左子节点存在并且大于父节点,则更新最大值索引 + if (l < n && arr[l] > arr[largest]) largest = l; + + // 如果右子节点存在并且大于父节点或左子节点,则更新最大值索引 + if (r < n && arr[r] > arr[largest]) largest = r; + + // 如果最大值索引不等于当前节点索引,则交换节点值,并递归进行堆化 + if (largest != i) { + int temp = numbers[i]; + numbers[i] = numbers[largest]; + numbers[largest] = temp; + heapify(arr, n, largest); + } + + await Future.delayed(getDuration()); // 延迟操作,用于可视化排序过程 + streamController.add(numbers); + } + + ///插入排序 + insertionSort() async { + for (int i = 1; i < numbers.length; i++) { + int temp = numbers[i]; // 将当前元素存储到临时变量 temp 中 + int j = i - 1; // j 表示已排序部分的最后一个元素的索引 + + // 在已排序部分从后往前查找,找到合适位置插入当前元素 + while (j >= 0 && temp < numbers[j]) { + numbers[j + 1] = numbers[j]; // 当前元素比已排序部分的元素小,将元素后移一位 + --j; // 向前遍历 + await Future.delayed(getDuration()); + streamController.add(numbers); // 更新排序结果 + } + + numbers[j + 1] = temp; // 插入当前元素到已排序部分的正确位置 + await Future.delayed(getDuration(), () {}); + streamController.add(numbers); // 更新排序结果 + } + } + + ///地精排序 (侏儒排序) + gnomeSort() async { + int index = 0; + + while (index < numbers.length) { + // 当 index 小于数组长度时执行循环 + if (index == 0) index++; + if (numbers[index] >= numbers[index - 1]) { + // 如果当前元素大于等于前面的元素,则将 index 加1 + index++; + } else { + // 否则,交换这两个元素,并将 index 减1(使得元素可以沉到正确位置) + int temp = numbers[index]; + numbers[index] = numbers[index - 1]; + numbers[index - 1] = temp; + index--; + } + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + return; + } + + ///奇偶排序(Odd-Even Sort) + oddEvenSort() async { + bool isSorted = false; + + while (!isSorted) { + // 当 isSorted 为 false 时执行循环 + isSorted = true; // 先假设数组已经排好序 + + for (int i = 1; i <= numbers.length - 2; i = i + 2) { + // 对奇数索引位置进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果当前元素大于后面的元素,则交换它们的值 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + isSorted = false; // 若发生了交换,则说明数组仍未完全排序,将 isSorted 设为 false + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + for (int i = 0; i <= numbers.length - 2; i = i + 2) { + // 对偶数索引位置进行比较 + if (numbers[i] > numbers[i + 1]) { + // 如果当前元素大于后面的元素,则交换它们的值 + int temp = numbers[i]; + numbers[i] = numbers[i + 1]; + numbers[i + 1] = temp; + isSorted = false; + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + } + + return; + } + + ///快速排序 + quickSort(int leftIndex, int rightIndex) async { + // 定义一个名为 _partition 的异步函数,用于划分数组,并返回划分后的基准元素的索引位置 + Future _partition(int left, int right) async{ + // 选择中间位置的元素作为基准元素 + int p = (left + (right - left) / 2).toInt(); + + // 交换基准元素和最右边的元素 + var temp = numbers[p]; + numbers[p] = numbers[right]; + numbers[right] = temp; + await Future.delayed(getDuration()); + streamController.add(numbers); + + // 初始化游标 cursor + int cursor = left; + + // 遍历数组并根据基准元素将元素交换到左侧或右侧 + for (int i = left; i < right; i++) { + if (cf(numbers[i], numbers[right]) <= 0) { + // 如果当前元素小于等于基准元素,则交换它和游标位置的元素 + var temp = numbers[i]; + numbers[i] = numbers[cursor]; + numbers[cursor] = temp; + cursor++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + // 将基准元素放置在游标位置 + temp = numbers[right]; + numbers[right] = numbers[cursor]; + numbers[cursor] = temp; + + await Future.delayed(getDuration()); + streamController.add(numbers); + + return cursor; // 返回基准元素的索引位置 + } + + // 如果左索引小于右索引,则递归地对数组进行快速排序 + if (leftIndex < rightIndex) { + int p = await _partition(leftIndex, rightIndex); + + await quickSort(leftIndex, p - 1); // 对基准元素左侧的子数组进行快速排序 + + await quickSort(p + 1, rightIndex); // 对基准元素右侧的子数组进行快速排序 + } + } + + // 比较函数,用于判断两个元素的大小关系 + cf(int a, int b) { + if (a < b) { + return -1; // 若 a 小于 b,则返回 -1 + } else if (a > b) { + return 1; // 若 a 大于 b,则返回 1 + } else { + return 0; // 若 a 等于 b,则返回 0 + } + } + + ///归并排序 + mergeSort(int leftIndex, int rightIndex) async { + // 定义一个名为 merge 的异步函数,用于合并两个有序子数组 + Future merge(int leftIndex, int middleIndex, int rightIndex) async { + // 计算左侧子数组和右侧子数组的大小 + int leftSize = middleIndex - leftIndex + 1; + int rightSize = rightIndex - middleIndex; + + // 创建左侧子数组和右侧子数组 + List leftList = List.generate(leftSize, (index) => 0); + List rightList = List.generate(rightSize, (index) => 0); + + // 将原始数组中的元素分别复制到左侧子数组和右侧子数组中 + for (int i = 0; i < leftSize; i++) { + leftList[i] = numbers[leftIndex + i]; + } + for (int j = 0; j < rightSize; j++) { + rightList[j] = numbers[middleIndex + j + 1]; + } + + // 初始化游标和索引 + int i = 0, j = 0; + int k = leftIndex; + + // 比较左侧子数组和右侧子数组的元素,并按顺序将较小的元素放入原始数组中 + while (i < leftSize && j < rightSize) { + if (leftList[i] <= rightList[j]) { + numbers[k] = leftList[i]; + i++; + } else { + numbers[k] = rightList[j]; + j++; + } + + await Future.delayed(getDuration()); + streamController.add(numbers); + + k++; + } + + // 将左侧子数组或右侧子数组中剩余的元素放入原始数组中 + while (i < leftSize) { + numbers[k] = leftList[i]; + i++; + k++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + + while (j < rightSize) { + numbers[k] = rightList[j]; + j++; + k++; + + await Future.delayed(getDuration()); + streamController.add(numbers); + } + } + + // 如果左索引小于右索引,则递归地对数组进行归并排序 + if (leftIndex < rightIndex) { + // 计算中间索引位置 + int middleIndex = (rightIndex + leftIndex) ~/ 2; + + // 分别对左侧子数组和右侧子数组进行归并排序 + await mergeSort(leftIndex, middleIndex); + await mergeSort(middleIndex + 1, rightIndex); + + await Future.delayed(getDuration()); + streamController.add(numbers); + + // 合并两个有序子数组 + await merge(leftIndex, middleIndex, rightIndex); + } + } + + checkAndResetIfSorted() async { + if (isSorted) { + reset(); + await Future.delayed(const Duration(milliseconds: 200)); + } + } + + sort() async { + setState(() { + isSorting = true; + }); + + await checkAndResetIfSorted(); + + Stopwatch stopwatch = Stopwatch()..start(); + + switch (currentSort) { + case "bubble": + await bubbleSort(); + break; + case "coctail": + await cocktailSort(); + break; + case "comb": + await combSort(); + break; + case "pigeonhole": + await pigeonHole(); + break; + case "shell": + await shellSort(); + break; + case "selection": + await selectionSort(); + break; + case "cycle": + await cycleSort(); + break; + case "heap": + await heapSort(); + break; + case "insertion": + await insertionSort(); + break; + case "gnome": + await gnomeSort(); + break; + case "oddeven": + await oddEvenSort(); + break; + case "quick": + await quickSort(0, sampleSize.toInt() - 1); + break; + case "merge": + await mergeSort(0, sampleSize.toInt() - 1); + break; + } + + stopwatch.stop(); + + print("Sorting completed in ${stopwatch.elapsed.inMilliseconds} ms."); + setState(() { + isSorting = false; + isSorted = true; + }); + } + + setSort(String type) { + setState(() { + currentSort = type; + }); + } + + @override + void initState() { + super.initState(); + // reset(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + sampleSize = MediaQuery.of(context).size.width / 2; + for (int i = 0; i < sampleSize; ++i) { + //随机往数组中填值 + numbers.add(Random().nextInt(500)); + } + setState(() {}); + } + + @override + void dispose() { + streamController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + "当前选择的是:${getTitle()}", + style: const TextStyle(fontSize: 14), + ), + actions: [ + PopupMenuButton( + initialValue: currentSort, + itemBuilder: (ctx) { + return const [ + PopupMenuItem( + value: 'bubble', + child: Text("Bubble Sort — 冒泡排序"), + ), + PopupMenuItem( + value: 'coctail', + child: Text("Coctail Sort — 鸡尾酒排序(双向冒泡排序)"), + ), + PopupMenuItem( + value: 'comb', + child: Text("Comb Sort — 梳排序"), + ), + PopupMenuItem( + value: 'pigeonhole', + child: Text("pigeonhole Sort — 鸽巢排序"), + ), + PopupMenuItem( + value: 'shell', + child: Text("shell Sort — 希尔排序"), + ), + PopupMenuItem( + value: 'selection', + child: Text("Selection Sort — 选择排序"), + ), + PopupMenuItem( + value: 'cycle', + child: Text("CycleSort — 循环排序"), + ), + PopupMenuItem( + value: 'heap', + child: Text("HeapSort — 堆排序"), + ), + PopupMenuItem( + value: 'insertion', + child: Text("InsertionSort — 插入排序"), + ), + PopupMenuItem( + value: 'gnome', + child: Text("GnomeSort — 地精排序 (侏儒排序)"), + ), + PopupMenuItem( + value: 'oddeven', + child: Text("OddEvenSort — 奇偶排序"), + ), + PopupMenuItem( + value: 'quick', + child: Text("QuickSort — 快速排序"), + ), + PopupMenuItem( + value: 'merge', + child: Text("MergeSort — 归并排序"), + ), + ]; + }, + onSelected: (String value) { + reset(); + setSort(value); + }, + ) + ], + ), + body: StreamBuilder( + initialData: numbers, + stream: streamController.stream, + builder: (context, snapshot) { + List numbers = snapshot.data as List; + int counter = 0; + return Row( + children: numbers.map((int num) { + counter++; + return CustomPaint( + painter: BarPainter( + width: MediaQuery.of(context).size.width / sampleSize, + value: num, + index: counter, + ), + ); + }).toList(), + ); + }, + ), + bottomNavigationBar: BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton( + onPressed: isSorting + ? null + : () { + reset(); + setSort(currentSort); + }, + child: const Text("重置")), + ElevatedButton( + onPressed: isSorting ? null : sort, child: const Text("开始排序")), + ElevatedButton( + onPressed: isSorting ? null : changeSpeed, + child: Text( + "${speed + 1}x", + style: const TextStyle(fontSize: 20), + ), + ), + ], + ), + ), + ); + } +} + +class BarPainter extends CustomPainter { + //宽度 + final double width; + + //高度(数组中对应的值) + final int value; + + //位置索引 + final int index; + + BarPainter({required this.width, required this.value, required this.index}); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint(); + if (value < 500 * .10) { + paint.color = Colors.blue.shade100; + } else if (value < 500 * .20) { + paint.color = Colors.blue.shade200; + } else if (value < 500 * .30) { + paint.color = Colors.blue.shade300; + } else if (value < 500 * .40) { + paint.color = Colors.blue.shade400; + } else if (value < 500 * .50) { + paint.color = Colors.blue.shade500; + } else if (value < 500 * .60) { + paint.color = Colors.blue.shade600; + } else if (value < 500 * .70) { + paint.color = Colors.blue.shade700; + } else if (value < 500 * .80) { + paint.color = Colors.blue.shade800; + } else if (value < 500 * .90) { + paint.color = Colors.blue.shade900; + } else { + paint.color = const Color(0xFF011E51); + } + + paint.strokeWidth = width; + paint.strokeCap = StrokeCap.round; + + canvas.drawLine( + Offset(index * width, 0), + Offset( + index * width, + value.ceilToDouble(), + ), + paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/v7/pages/user/user_page.dart b/lib/v7/pages/user/user_page.dart new file mode 100644 index 0000000..aba9710 --- /dev/null +++ b/lib/v7/pages/user/user_page.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class UserPage extends StatelessWidget { + const UserPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body:Center(child: Text('UserPage'))); + } +} diff --git a/test/tree/main.dart b/test/tree/main.dart new file mode 100644 index 0000000..8fc2c02 --- /dev/null +++ b/test/tree/main.dart @@ -0,0 +1,63 @@ +import 'node.dart'; + +main(){ + /// 需求:在树中寻找满足需求的节点列表 + /// input: 2-3-1 + /// 输出节点 2, 2-3,2-3-1 + // findNodes(root, '2-2', 0,''); + // List nodes = findNodes(root, '2-3-1', 0,'',[]); + List nodes = find( '/2/3/1'); + print(nodes); +} + +List find(String input){ + String fixInput = input.substring(1); + List nodes = findNodes(root2,fixInput,0,'/',[]); + if(nodes.isNotEmpty&&nodes.last.value!=input){ + return []; + } + return nodes; +} + + +List findNodes(Node node,String input,int deep,String prefix,List result){ + List parts = input.split('/'); + if(deep>parts.length-1){ + return result; + } + String target = parts[deep]; + if(node.children.isNotEmpty){ + List nodes = node.children.where((e) => e.value==prefix+target).toList(); + bool match = nodes.isNotEmpty; + if(match){ + Node matched = nodes.first; + result.add(matched); + String nextPrefix = '${matched.value}/'; + findNodes(matched, input, ++deep,nextPrefix,result); + } + }else{ + return result; + } + return result; +} + +// List findNodes(Node node,String input,int deep,String prefix,List result){ +// List parts = input.split('-'); +// if(deep>parts.length-1){ +// return result; +// } +// String target = parts[deep]; +// if(node.children.isNotEmpty){ +// List nodes = node.children.where((e) => e.value==prefix+target).toList(); +// bool match = nodes.isNotEmpty; +// if(match){ +// Node matched = nodes.first; +// result.add(matched); +// String nextPrefix = '${matched.value}-'; +// findNodes(matched, input, ++deep,nextPrefix,result); +// } +// }else{ +// return result; +// } +// return result; +// } \ No newline at end of file diff --git a/test/tree/node.dart b/test/tree/node.dart new file mode 100644 index 0000000..a4312ea --- /dev/null +++ b/test/tree/node.dart @@ -0,0 +1,71 @@ +class Node { + final String value; + final List children; + + Node({required this.value, this.children = const []}); + + @override + String toString() { + return 'Node{value: $value}'; + } +} + +Node root = Node(value: 'root', children: [ + Node( + value: '1', + children: [ + Node(value: '1-1'), + Node(value: '1-2'), + Node(value: '1-3'), + ], + ), + Node( + value: '2', + children: [ + Node(value: '2-1'), + Node(value: '2-2'), + Node(value: '2-3',children: [ + Node(value: '2-3-1',), + ]), + ], + ), + Node( + value: '3', + children: [ + Node(value: '3-1'), + Node(value: '3-2', children: [ + Node(value: '3-2-1',), + ]), + ], + ), +]); + +Node root2 = Node(value: '/', children: [ + Node( + value: '/1', + children: [ + Node(value: '/1/1'), + Node(value: '/1/2'), + Node(value: '/1/3'), + ], + ), + Node( + value: '/2', + children: [ + Node(value: '/2/1'), + Node(value: '/2/2'), + Node(value: '/2/3',children: [ + Node(value: '/2/3/1',), + ]), + ], + ), + Node( + value: '/3', + children: [ + Node(value: '/3/1'), + Node(value: '/3/2', children: [ + Node(value: '/3/2/1',), + ]), + ], + ), +]); \ No newline at end of file