From 8a85a838e7c7f7465d70f584f1fedac5cc40b7c8 Mon Sep 17 00:00:00 2001 From: jyuesong <425698907@qq.com> Date: Wed, 19 Jan 2022 13:30:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=BB=E5=8A=A1=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/base/http/api.dart | 14 +- lib/base/routes.dart | 10 +- lib/base/ui/ql_context_menu.dart | 1216 +++++++++++++++++ lib/json.jc.dart | 13 +- .../dependencies/add_dependency_page.dart | 3 - lib/module/task/add_task_page.dart | 3 +- .../task/task_detail/task_detail_bean.dart | 67 - .../task/task_detail/task_detail_page.dart | 186 ++- .../task_detail/task_detail_viewmodel.dart | 29 - lib/module/task/task_page.dart | 94 +- lib/utils/utils.dart | 3 + 11 files changed, 1427 insertions(+), 211 deletions(-) create mode 100644 lib/base/ui/ql_context_menu.dart delete mode 100644 lib/module/task/task_detail/task_detail_bean.dart delete mode 100644 lib/module/task/task_detail/task_detail_viewmodel.dart diff --git a/lib/base/http/api.dart b/lib/base/http/api.dart index 304ce36..e299d67 100644 --- a/lib/base/http/api.dart +++ b/lib/base/http/api.dart @@ -8,7 +8,6 @@ import 'package:qinglong_app/module/others/login_log/login_log_bean.dart'; import 'package:qinglong_app/module/others/scripts/script_bean.dart'; import 'package:qinglong_app/module/others/task_log/task_log_bean.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; import 'url.dart'; @@ -58,24 +57,17 @@ class Api { ); } - static Future> taskDetail(String cron) async { - return await Http.get( - Url.taskDetail + cron, - null, - ); - } - - static Future> addTask(String name, String command, String cron, {String? id}) async { + static Future> addTask(String name, String command, String cron, {String? id}) async { var data = {"name": name, "command": command, "schedule": cron}; if (id != null) { data["_id"] = id; - return await Http.put( + return await Http.put( Url.addTask, data, ); } - return await Http.post( + return await Http.post( Url.addTask, data, ); diff --git a/lib/base/routes.dart b/lib/base/routes.dart index 7ffdb89..9491b9e 100644 --- a/lib/base/routes.dart +++ b/lib/base/routes.dart @@ -14,11 +14,13 @@ import 'package:qinglong_app/module/others/task_log/task_log_detail_page.dart'; import 'package:qinglong_app/module/others/task_log/task_log_page.dart'; import 'package:qinglong_app/module/task/add_task_page.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; +import 'package:qinglong_app/module/task/task_detail/task_detail_page.dart'; class Routes { static const String routeHomePage = "/home/homepage"; static const String routeLogin = "/login"; static const String routeAddTask = "/task/add"; + static const String routeTaskDetail = "/task/detail"; static const String routeAddDependency = "/task/dependency"; static const String routeAddEnv = "/env/add"; static const String routeConfigEdit = "/config/edit"; @@ -45,7 +47,7 @@ class Routes { return CupertinoPageRoute(builder: (context) => const AddTaskPage()); } case routeAddDependency: - return CupertinoPageRoute(builder: (context) => const AddDependenyPage()); + return CupertinoPageRoute(builder: (context) => const AddDependenyPage()); case routeAddEnv: if (settings.arguments != null) { return CupertinoPageRoute( @@ -91,6 +93,12 @@ class Routes { path: (settings.arguments as Map)["path"], ), ); + case routeTaskDetail: + return CupertinoPageRoute( + builder: (context) => TaskDetailPage( + settings.arguments as TaskBean, + ), + ); } return null; diff --git a/lib/base/ui/ql_context_menu.dart b/lib/base/ui/ql_context_menu.dart new file mode 100644 index 0000000..ea3c325 --- /dev/null +++ b/lib/base/ui/ql_context_menu.dart @@ -0,0 +1,1216 @@ +/// @author NewTab +// 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 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/gestures.dart' show kMinFlingVelocity, kLongPressTimeout; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:qinglong_app/module/task/task_bean.dart'; + +import '../routes.dart'; + +// The scale of the child at the time that the CupertinoContextMenu opens. +// This value was eyeballed from a physical device running iOS 13.1.2. +const double _kOpenScale = 1.1; + +typedef _DismissCallback = void Function( + BuildContext context, + double scale, + double opacity, +); + +/// A function that produces the preview when the CupertinoContextMenu is open. +/// +/// Called every time the animation value changes. +typedef ContextMenuPreviewBuilder = Widget Function( + BuildContext context, + Animation animation, + Widget child, +); + +// A function that proxies to ContextMenuPreviewBuilder without the child. +typedef _ContextMenuPreviewBuilderChildless = Widget Function( + BuildContext context, + Animation animation, +); + +// Given a GlobalKey, return the Rect of the corresponding RenderBox's +// paintBounds in global coordinates. +Rect _getRect(GlobalKey globalKey) { + assert(globalKey.currentContext != null); + final RenderBox renderBoxContainer = globalKey.currentContext!.findRenderObject()! as RenderBox; + final Offset containerOffset = renderBoxContainer.localToGlobal( + renderBoxContainer.paintBounds.topLeft, + ); + return containerOffset & renderBoxContainer.paintBounds.size; +} + +// The context menu arranges itself slightly differently based on the location +// on the screen of [CupertinoContextMenu.child] before the +// [CupertinoContextMenu] opens. +enum _ContextMenuLocation { + center, + left, + right, +} + +/// A full-screen modal route that opens when the [child] is long-pressed. +/// +/// When open, the [QlCupertinoContextMenu] shows the child, or the widget returned +/// by [previewBuilder] if given, in a large full-screen [Overlay] with a list +/// of buttons specified by [actions]. The child/preview is placed in an +/// [Expanded] widget so that it will grow to fill the Overlay if its size is +/// unconstrained. +/// +/// When closed, the CupertinoContextMenu simply displays the child as if the +/// CupertinoContextMenu were not there. Sizing and positioning is unaffected. +/// The menu can be closed like other [PopupRoute]s, such as by tapping the +/// background or by calling `Navigator.pop(context)`. Unlike PopupRoute, it can +/// also be closed by swiping downwards. +/// +/// The [previewBuilder] parameter is most commonly used to display a slight +/// variation of [child]. See [previewBuilder] for an example of rounding the +/// child's corners and allowing its aspect ratio to expand, similar to the +/// Photos app on iOS. +/// +/// {@tool dartpad} +/// This sample shows a very simple CupertinoContextMenu for an empty red +/// 100x100 Container. Simply long press on it to open. +/// +/// ** See code in examples/api/lib/cupertino/context_menu/cupertino_context_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Apple's HIG for Context Menus](https://developer.apple.com/design/human-interface-guidelines/ios/controls/context-menus/) +class QlCupertinoContextMenu extends StatefulWidget { + /// Create a context menu. + /// + /// [actions] is required and cannot be null or empty. + /// + /// [child] is required and cannot be null. + QlCupertinoContextMenu({ + Key? key, + required this.actions, + required this.child, + required this.bean, + this.previewBuilder, + }) : assert(actions != null && actions.isNotEmpty), + assert(child != null), + super(key: key); + + final TaskBean bean; + + /// The widget that can be "opened" with the [QlCupertinoContextMenu]. + /// + /// When the [QlCupertinoContextMenu] is long-pressed, the menu will open and + /// this widget (or the widget returned by [previewBuilder], if provided) will + /// be moved to the new route and placed inside of an [Expanded] widget. This + /// allows the child to resize to fit in its place in the new route, if it + /// doesn't size itself. + /// + /// When the [QlCupertinoContextMenu] is "closed", this widget acts like a + /// [Container], i.e. it does not constrain its child's size or affect its + /// position. + /// + /// This parameter cannot be null. + final Widget child; + + /// The actions that are shown in the menu. + /// + /// These actions are typically [CupertinoContextMenuAction]s. + /// + /// This parameter cannot be null or empty. + final List actions; + + /// A function that returns an alternative widget to show when the + /// [QlCupertinoContextMenu] is open. + /// + /// If not specified, [child] will be shown. + /// + /// The preview is often used to show a slight variation of the [child]. For + /// example, the child could be given rounded corners in the preview but have + /// sharp corners when in the page. + /// + /// In addition to the current [BuildContext], the function is also called + /// with an [Animation] and the [child]. The animation goes from 0 to 1 when + /// the CupertinoContextMenu opens, and from 1 to 0 when it closes, and it can + /// be used to animate the preview in sync with this opening and closing. The + /// child parameter provides access to the child displayed when the + /// CupertinoContextMenu is closed. + /// + /// {@tool snippet} + /// + /// Below is an example of using `previewBuilder` to show an image tile that's + /// similar to each tile in the iOS iPhoto app's context menu. Several of + /// these could be used in a GridView for a similar effect. + /// + /// When opened, the child animates to show its full aspect ratio and has + /// rounded corners. The larger size of the open CupertinoContextMenu allows + /// the FittedBox to fit the entire image, even when it has a very tall or + /// wide aspect ratio compared to the square of a GridView, so this animates + /// into view as the CupertinoContextMenu is opened. The preview is swapped in + /// right when the open animation begins, which includes the rounded corners. + /// + /// ```dart + /// CupertinoContextMenu( + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: Image.asset('assets/photo.jpg'), + /// ), + /// // The FittedBox in the preview here allows the image to animate its + /// // aspect ratio when the CupertinoContextMenu is animating its preview + /// // widget open and closed. + /// previewBuilder: (BuildContext context, Animation animation, Widget child) { + /// return FittedBox( + /// fit: BoxFit.cover, + /// // This ClipRRect rounds the corners of the image when the + /// // CupertinoContextMenu is open, even though it's not rounded when + /// // it's closed. It uses the given animation to animate the corners + /// // in sync with the opening animation. + /// child: ClipRRect( + /// borderRadius: BorderRadius.circular(64.0 * animation.value), + /// child: Image.asset('assets/photo.jpg'), + /// ), + /// ); + /// }, + /// actions: [ + /// CupertinoContextMenuAction( + /// child: const Text('Action one'), + /// onPressed: () {}, + /// ), + /// ], + /// ) + /// ``` + /// + /// {@end-tool} + final ContextMenuPreviewBuilder? previewBuilder; + + @override + State createState() => _QlCupertinoContextMenuState(); +} + +class _QlCupertinoContextMenuState extends State with TickerProviderStateMixin { + final GlobalKey _childGlobalKey = GlobalKey(); + bool _childHidden = false; + + // Animates the child while it's opening. + late AnimationController _openController; + Rect? _decoyChildEndRect; + OverlayEntry? _lastOverlayEntry; + _ContextMenuRoute? _route; + + @override + void initState() { + super.initState(); + _openController = AnimationController( + duration: kLongPressTimeout, + vsync: this, + ); + _openController.addStatusListener(_onDecoyAnimationStatusChange); + } + + // Determine the _ContextMenuLocation based on the location of the original + // child in the screen. + // + // The location of the original child is used to determine how to horizontally + // align the content of the open CupertinoContextMenu. For example, if the + // child is near the center of the screen, it will also appear in the center + // of the screen when the menu is open, and the actions will be centered below + // it. + _ContextMenuLocation get _contextMenuLocation { + final Rect childRect = _getRect(_childGlobalKey); + final double screenWidth = MediaQuery.of(context).size.width; + + final double center = screenWidth / 2; + final bool centerDividesChild = childRect.left < center && childRect.right > center; + final double distanceFromCenter = (center - childRect.center.dx).abs(); + if (centerDividesChild && distanceFromCenter <= childRect.width / 4) { + return _ContextMenuLocation.center; + } + + if (childRect.center.dx > center) { + return _ContextMenuLocation.right; + } + + return _ContextMenuLocation.left; + } + + // Push the new route and open the CupertinoContextMenu overlay. + void _openContextMenu() { + setState(() { + _childHidden = true; + }); + + _route = _ContextMenuRoute( + actions: widget.actions, + barrierLabel: 'Dismiss', + filter: ui.ImageFilter.blur( + sigmaX: 5.0, + sigmaY: 5.0, + ), + contextMenuLocation: _contextMenuLocation, + previousChildRect: _decoyChildEndRect!, + builder: (BuildContext context, Animation animation) { + if (widget.previewBuilder == null) { + return widget.child; + } + return widget.previewBuilder!(context, animation, widget.child); + }, + ); + Navigator.of(context, rootNavigator: true).push(_route!); + _route!.animation!.addStatusListener(_routeAnimationStatusListener); + } + + void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) { + switch (animationStatus) { + case AnimationStatus.dismissed: + if (_route == null) { + setState(() { + _childHidden = false; + }); + } + _lastOverlayEntry?.remove(); + _lastOverlayEntry = null; + break; + + case AnimationStatus.completed: + setState(() { + _childHidden = true; + }); + _openContextMenu(); + // Keep the decoy on the screen for one extra frame. We have to do this + // because _ContextMenuRoute renders its first frame offscreen. + // Otherwise there would be a visible flash when nothing is rendered for + // one frame. + SchedulerBinding.instance!.addPostFrameCallback((Duration _) { + _lastOverlayEntry?.remove(); + _lastOverlayEntry = null; + _openController.reset(); + }); + break; + + case AnimationStatus.forward: + case AnimationStatus.reverse: + return; + } + } + + // Watch for when _ContextMenuRoute is closed and return to the state where + // the CupertinoContextMenu just behaves as a Container. + void _routeAnimationStatusListener(AnimationStatus status) { + if (status != AnimationStatus.dismissed) { + return; + } + setState(() { + _childHidden = false; + }); + _route!.animation!.removeStatusListener(_routeAnimationStatusListener); + _route = null; + } + + void _onTap() { + if (_openController.isAnimating && _openController.value < 0.5) { + _openController.reverse(); + } else { + if (_openController.isDismissed) { + Navigator.of(context).pushNamed(Routes.routeTaskDetail, arguments: widget.bean); + } + } + } + + void _onTapCancel() { + if (_openController.isAnimating && _openController.value < 0.5) { + _openController.reverse(); + } + } + + void _onTapUp(TapUpDetails details) { + if (_openController.isAnimating && _openController.value < 0.5) { + _openController.reverse(); + } + } + + void _onTapDown(TapDownDetails details) { + setState(() { + _childHidden = true; + }); + + final Rect childRect = _getRect(_childGlobalKey); + _decoyChildEndRect = Rect.fromCenter( + center: childRect.center, + width: childRect.width * _kOpenScale, + height: childRect.height * _kOpenScale, + ); + + // Create a decoy child in an overlay directly on top of the original child. + // TODO(justinmc): There is a known inconsistency with native here, due to + // doing the bounce animation using a decoy in the top level Overlay. The + // decoy will pop on top of the AppBar if the child is partially behind it, + // such as a top item in a partially scrolled view. However, if we don't use + // an overlay, then the decoy will appear behind its neighboring widget when + // it expands. This may be solvable by adding a widget to Scaffold that's + // underneath the AppBar. + _lastOverlayEntry = OverlayEntry( + builder: (BuildContext context) { + return _DecoyChild( + beginRect: childRect, + controller: _openController, + endRect: _decoyChildEndRect, + child: widget.child, + ); + }, + ); + Overlay.of(context, rootOverlay: true)!.insert(_lastOverlayEntry!); + _openController.forward(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapCancel: _onTapCancel, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTap: _onTap, + child: TickerMode( + enabled: !_childHidden, + child: Opacity( + key: _childGlobalKey, + opacity: _childHidden ? 0.0 : 1.0, + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _openController.dispose(); + super.dispose(); + } +} + +// A floating copy of the CupertinoContextMenu's child. +// +// When the child is pressed, but before the CupertinoContextMenu opens, it does +// a "bounce" animation where it shrinks and then grows. This is implemented +// by hiding the original child and placing _DecoyChild on top of it in an +// Overlay. The use of an Overlay allows the _DecoyChild to appear on top of +// siblings of the original child. +class _DecoyChild extends StatefulWidget { + const _DecoyChild({ + Key? key, + this.beginRect, + required this.controller, + this.endRect, + this.child, + }) : super(key: key); + + final Rect? beginRect; + final AnimationController controller; + final Rect? endRect; + final Widget? child; + + @override + _DecoyChildState createState() => _DecoyChildState(); +} + +class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin { + // TODO(justinmc): Dark mode support. + // See https://github.com/flutter/flutter/issues/43211. + static const Color _lightModeMaskColor = Color(0xFF888888); + static const Color _masklessColor = Color(0xFFFFFFFF); + + final GlobalKey _childGlobalKey = GlobalKey(); + late Animation _mask; + late Animation _rect; + + @override + void initState() { + super.initState(); + // Change the color of the child during the initial part of the decoy bounce + // animation. The interval was eyeballed from a physical iOS 13.1.2 device. + _mask = _OnOffAnimation( + controller: widget.controller, + onValue: _lightModeMaskColor, + offValue: _masklessColor, + intervalOn: 0.0, + intervalOff: 0.5, + ); + + final Rect midRect = widget.beginRect!.deflate( + widget.beginRect!.width * (_kOpenScale - 1.0) / 2, + ); + _rect = TweenSequence(>[ + TweenSequenceItem( + tween: RectTween( + begin: widget.beginRect, + end: midRect, + ).chain(CurveTween(curve: Curves.easeInOutCubic)), + weight: 1.0, + ), + TweenSequenceItem( + tween: RectTween( + begin: midRect, + end: widget.endRect, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + weight: 1.0, + ), + ]).animate(widget.controller); + _rect.addListener(_rectListener); + } + + // Listen to the _rect animation and vibrate when it reaches the halfway point + // and switches from animating down to up. + void _rectListener() { + if (widget.controller.value < 0.5) { + return; + } + HapticFeedback.selectionClick(); + _rect.removeListener(_rectListener); + } + + @override + void dispose() { + _rect.removeListener(_rectListener); + super.dispose(); + } + + Widget _buildAnimation(BuildContext context, Widget? child) { + final Color color = widget.controller.status == AnimationStatus.reverse ? _masklessColor : _mask.value; + return Positioned.fromRect( + rect: _rect.value!, + child: ShaderMask( + key: _childGlobalKey, + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, color], + ).createShader(bounds); + }, + child: widget.child, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + AnimatedBuilder( + builder: _buildAnimation, + animation: widget.controller, + ), + ], + ); + } +} + +// The open CupertinoContextMenu modal. +class _ContextMenuRoute extends PopupRoute { + // Build a _ContextMenuRoute. + _ContextMenuRoute({ + required List actions, + required _ContextMenuLocation contextMenuLocation, + this.barrierLabel, + _ContextMenuPreviewBuilderChildless? builder, + ui.ImageFilter? filter, + required Rect previousChildRect, + RouteSettings? settings, + }) : assert(actions != null && actions.isNotEmpty), + assert(contextMenuLocation != null), + _actions = actions, + _builder = builder, + _contextMenuLocation = contextMenuLocation, + _previousChildRect = previousChildRect, + super( + filter: filter, + settings: settings, + ); + + // Barrier color for a Cupertino modal barrier. + static const Color _kModalBarrierColor = Color(0x6604040F); + + // The duration of the transition used when a modal popup is shown. Eyeballed + // from a physical device running iOS 13.1.2. + static const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + + final List _actions; + final _ContextMenuPreviewBuilderChildless? _builder; + final GlobalKey _childGlobalKey = GlobalKey(); + final _ContextMenuLocation _contextMenuLocation; + bool _externalOffstage = false; + bool _internalOffstage = false; + Orientation? _lastOrientation; + + // The Rect of the child at the moment that the CupertinoContextMenu opens. + final Rect _previousChildRect; + double? _scale = 1.0; + final GlobalKey _sheetGlobalKey = GlobalKey(); + + static final CurveTween _curve = CurveTween( + curve: Curves.easeOutBack, + ); + static final CurveTween _curveReverse = CurveTween( + curve: Curves.easeInBack, + ); + static final RectTween _rectTween = RectTween(); + static final Animatable _rectAnimatable = _rectTween.chain(_curve); + static final RectTween _rectTweenReverse = RectTween(); + static final Animatable _rectAnimatableReverse = _rectTweenReverse.chain( + _curveReverse, + ); + static final RectTween _sheetRectTween = RectTween(); + final Animatable _sheetRectAnimatable = _sheetRectTween.chain( + _curve, + ); + final Animatable _sheetRectAnimatableReverse = _sheetRectTween.chain( + _curveReverse, + ); + static final Tween _sheetScaleTween = Tween(); + static final Animatable _sheetScaleAnimatable = _sheetScaleTween.chain( + _curve, + ); + static final Animatable _sheetScaleAnimatableReverse = _sheetScaleTween.chain( + _curveReverse, + ); + final Tween _opacityTween = Tween(begin: 0.0, end: 1.0); + late Animation _sheetOpacity; + + @override + final String? barrierLabel; + + @override + Color get barrierColor => _kModalBarrierColor; + + @override + bool get barrierDismissible => true; + + @override + bool get semanticsDismissible => false; + + @override + Duration get transitionDuration => _kModalPopupTransitionDuration; + + // Getting the RenderBox doesn't include the scale from the Transform.scale, + // so it's manually accounted for here. + static Rect _getScaledRect(GlobalKey globalKey, double scale) { + final Rect childRect = _getRect(globalKey); + final Size sizeScaled = childRect.size * scale; + final Offset offsetScaled = Offset( + childRect.left + (childRect.size.width - sizeScaled.width) / 2, + childRect.top + (childRect.size.height - sizeScaled.height) / 2, + ); + return offsetScaled & sizeScaled; + } + + // Get the alignment for the _ContextMenuSheet's Transform.scale based on the + // contextMenuLocation. + static AlignmentDirectional getSheetAlignment(_ContextMenuLocation contextMenuLocation) { + switch (contextMenuLocation) { + case _ContextMenuLocation.center: + return AlignmentDirectional.topCenter; + case _ContextMenuLocation.right: + return AlignmentDirectional.topEnd; + case _ContextMenuLocation.left: + return AlignmentDirectional.topStart; + } + } + + // The place to start the sheetRect animation from. + static Rect _getSheetRectBegin(Orientation? orientation, _ContextMenuLocation contextMenuLocation, Rect childRect, Rect sheetRect) { + switch (contextMenuLocation) { + case _ContextMenuLocation.center: + final Offset target = orientation == Orientation.portrait ? childRect.bottomCenter : childRect.topCenter; + final Offset centered = target - Offset(sheetRect.width / 2, 0.0); + return centered & sheetRect.size; + case _ContextMenuLocation.right: + final Offset target = orientation == Orientation.portrait ? childRect.bottomRight : childRect.topRight; + return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size; + case _ContextMenuLocation.left: + final Offset target = orientation == Orientation.portrait ? childRect.bottomLeft : childRect.topLeft; + return target & sheetRect.size; + } + } + + void _onDismiss(BuildContext context, double scale, double opacity) { + _scale = scale; + _opacityTween.end = opacity; + _sheetOpacity = _opacityTween.animate(CurvedAnimation( + parent: animation!, + curve: const Interval(0.9, 1.0), + )); + Navigator.of(context).pop(); + } + + // Take measurements on the child and _ContextMenuSheet and update the + // animation tweens to match. + void _updateTweenRects() { + final Rect childRect = _scale == null ? _getRect(_childGlobalKey) : _getScaledRect(_childGlobalKey, _scale!); + _rectTween.begin = _previousChildRect; + _rectTween.end = childRect; + + // When opening, the transition happens from the end of the child's bounce + // animation to the final state. When closing, it goes from the final state + // to the original position before the bounce. + final Rect childRectOriginal = Rect.fromCenter( + center: _previousChildRect.center, + width: _previousChildRect.width / _kOpenScale, + height: _previousChildRect.height / _kOpenScale, + ); + + final Rect sheetRect = _getRect(_sheetGlobalKey); + final Rect sheetRectBegin = _getSheetRectBegin( + _lastOrientation, + _contextMenuLocation, + childRectOriginal, + sheetRect, + ); + _sheetRectTween.begin = sheetRectBegin; + _sheetRectTween.end = sheetRect; + _sheetScaleTween.begin = 0.0; + _sheetScaleTween.end = _scale; + + _rectTweenReverse.begin = childRectOriginal; + _rectTweenReverse.end = childRect; + } + + void _setOffstageInternally() { + super.offstage = _externalOffstage || _internalOffstage; + // It's necessary to call changedInternalState to get the backdrop to + // update. + changedInternalState(); + } + + @override + bool didPop(T? result) { + _updateTweenRects(); + return super.didPop(result); + } + + @override + set offstage(bool value) { + _externalOffstage = value; + _setOffstageInternally(); + } + + @override + TickerFuture didPush() { + _internalOffstage = true; + _setOffstageInternally(); + + // Render one frame offstage in the final position so that we can take + // measurements of its layout and then animate to them. + SchedulerBinding.instance!.addPostFrameCallback((Duration _) { + _updateTweenRects(); + _internalOffstage = false; + _setOffstageInternally(); + }); + return super.didPush(); + } + + @override + Animation createAnimation() { + final Animation animation = super.createAnimation(); + _sheetOpacity = _opacityTween.animate(CurvedAnimation( + parent: animation, + curve: Curves.linear, + )); + return animation; + } + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + // This is usually used to build the "page", which is then passed to + // buildTransitions as child, the idea being that buildTransitions will + // animate the entire page into the scene. In the case of _ContextMenuRoute, + // two individual pieces of the page are animated into the scene in + // buildTransitions, and a Container is returned here. + return Container(); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + _lastOrientation = orientation; + + // While the animation is running, render everything in a Stack so that + // they're movable. + if (!animation.isCompleted) { + final bool reverse = animation.status == AnimationStatus.reverse; + final Rect rect = reverse ? _rectAnimatableReverse.evaluate(animation)! : _rectAnimatable.evaluate(animation)!; + final Rect sheetRect = reverse ? _sheetRectAnimatableReverse.evaluate(animation)! : _sheetRectAnimatable.evaluate(animation)!; + final double sheetScale = reverse ? _sheetScaleAnimatableReverse.evaluate(animation) : _sheetScaleAnimatable.evaluate(animation); + return Stack( + children: [ + Positioned.fromRect( + rect: sheetRect, + child: FadeTransition( + opacity: _sheetOpacity, + child: Transform.scale( + alignment: getSheetAlignment(_contextMenuLocation), + scale: sheetScale, + child: _ContextMenuSheet( + key: _sheetGlobalKey, + actions: _actions, + contextMenuLocation: _contextMenuLocation, + orientation: orientation, + ), + ), + ), + ), + Positioned.fromRect( + key: _childGlobalKey, + rect: rect, + child: _builder!(context, animation), + ), + ], + ); + } + + // When the animation is done, just render everything in a static layout + // in the final position. + return _ContextMenuRouteStatic( + actions: _actions, + childGlobalKey: _childGlobalKey, + contextMenuLocation: _contextMenuLocation, + onDismiss: _onDismiss, + orientation: orientation, + sheetGlobalKey: _sheetGlobalKey, + child: _builder!(context, animation), + ); + }, + ); + } +} + +// The final state of the _ContextMenuRoute after animating in and before +// animating out. +class _ContextMenuRouteStatic extends StatefulWidget { + const _ContextMenuRouteStatic({ + Key? key, + this.actions, + required this.child, + this.childGlobalKey, + required this.contextMenuLocation, + this.onDismiss, + required this.orientation, + this.sheetGlobalKey, + }) : assert(contextMenuLocation != null), + assert(orientation != null), + super(key: key); + + final List? actions; + final Widget child; + final GlobalKey? childGlobalKey; + final _ContextMenuLocation contextMenuLocation; + final _DismissCallback? onDismiss; + final Orientation orientation; + final GlobalKey? sheetGlobalKey; + + @override + _ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState(); +} + +class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> with TickerProviderStateMixin { + // The child is scaled down as it is dragged down until it hits this minimum + // value. + static const double _kMinScale = 0.8; + + // The CupertinoContextMenuSheet disappears at this scale. + static const double _kSheetScaleThreshold = 0.9; + static const double _kPadding = 20.0; + static const double _kDamping = 400.0; + static const Duration _kMoveControllerDuration = Duration(milliseconds: 600); + + late Offset _dragOffset; + double _lastScale = 1.0; + late AnimationController _moveController; + late AnimationController _sheetController; + late Animation _moveAnimation; + late Animation _sheetScaleAnimation; + late Animation _sheetOpacityAnimation; + + // The scale of the child changes as a function of the distance it is dragged. + static double _getScale(Orientation orientation, double maxDragDistance, double dy) { + final double dyDirectional = dy <= 0.0 ? dy : -dy; + return math.max( + _kMinScale, + (maxDragDistance + dyDirectional) / maxDragDistance, + ); + } + + void _onPanStart(DragStartDetails details) { + _moveController.value = 1.0; + _setDragOffset(Offset.zero); + } + + void _onPanUpdate(DragUpdateDetails details) { + _setDragOffset(_dragOffset + details.delta); + } + + void _onPanEnd(DragEndDetails details) { + // If flung, animate a bit before handling the potential dismiss. + if (details.velocity.pixelsPerSecond.dy.abs() >= kMinFlingVelocity) { + final bool flingIsAway = details.velocity.pixelsPerSecond.dy > 0; + final double finalPosition = flingIsAway ? _moveAnimation.value.dy + 100.0 : 0.0; + + if (flingIsAway && _sheetController.status != AnimationStatus.forward) { + _sheetController.forward(); + } else if (!flingIsAway && _sheetController.status != AnimationStatus.reverse) { + _sheetController.reverse(); + } + + _moveAnimation = Tween( + begin: Offset(0.0, _moveAnimation.value.dy), + end: Offset(0.0, finalPosition), + ).animate(_moveController); + _moveController.reset(); + _moveController.duration = const Duration( + milliseconds: 64, + ); + _moveController.forward(); + _moveController.addStatusListener(_flingStatusListener); + return; + } + + // Dismiss if the drag is enough to scale down all the way. + if (_lastScale == _kMinScale) { + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + return; + } + + // Otherwise animate back home. + _moveController.addListener(_moveListener); + _moveController.reverse(); + } + + void _moveListener() { + // When the scale passes the threshold, animate the sheet back in. + if (_lastScale > _kSheetScaleThreshold) { + _moveController.removeListener(_moveListener); + if (_sheetController.status != AnimationStatus.dismissed) { + _sheetController.reverse(); + } + } + } + + void _flingStatusListener(AnimationStatus status) { + if (status != AnimationStatus.completed) { + return; + } + + // Reset the duration back to its original value. + _moveController.duration = _kMoveControllerDuration; + + _moveController.removeStatusListener(_flingStatusListener); + // If it was a fling back to the start, it has reset itself, and it should + // not be dismissed. + if (_moveAnimation.value.dy == 0.0) { + return; + } + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + } + + Alignment _getChildAlignment(Orientation orientation, _ContextMenuLocation contextMenuLocation) { + switch (contextMenuLocation) { + case _ContextMenuLocation.center: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; + case _ContextMenuLocation.right: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topLeft; + case _ContextMenuLocation.left: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; + } + } + + void _setDragOffset(Offset dragOffset) { + // Allow horizontal and negative vertical movement, but damp it. + final double endX = _kPadding * dragOffset.dx / _kDamping; + final double endY = dragOffset.dy >= 0.0 ? dragOffset.dy : _kPadding * dragOffset.dy / _kDamping; + setState(() { + _dragOffset = dragOffset; + _moveAnimation = Tween( + begin: Offset.zero, + end: Offset( + endX.clamp(-_kPadding, _kPadding), + endY, + ), + ).animate( + CurvedAnimation( + parent: _moveController, + curve: Curves.elasticIn, + ), + ); + + // Fade the _ContextMenuSheet out or in, if needed. + if (_lastScale <= _kSheetScaleThreshold && _sheetController.status != AnimationStatus.forward && _sheetScaleAnimation.value != 0.0) { + _sheetController.forward(); + } else if (_lastScale > _kSheetScaleThreshold && _sheetController.status != AnimationStatus.reverse && _sheetScaleAnimation.value != 1.0) { + _sheetController.reverse(); + } + }); + } + + // The order and alignment of the _ContextMenuSheet and the child depend on + // both the orientation of the screen as well as the position on the screen of + // the original child. + List _getChildren(Orientation orientation, _ContextMenuLocation contextMenuLocation) { + final Expanded child = Expanded( + child: Align( + alignment: _getChildAlignment( + widget.orientation, + widget.contextMenuLocation, + ), + child: AnimatedBuilder( + animation: _moveController, + builder: _buildChildAnimation, + child: widget.child, + ), + ), + ); + const SizedBox spacer = SizedBox( + width: _kPadding, + height: _kPadding, + ); + final Expanded sheet = Expanded( + child: AnimatedBuilder( + animation: _sheetController, + builder: _buildSheetAnimation, + child: _ContextMenuSheet( + key: widget.sheetGlobalKey, + actions: widget.actions!, + contextMenuLocation: widget.contextMenuLocation, + orientation: widget.orientation, + ), + ), + ); + + switch (contextMenuLocation) { + case _ContextMenuLocation.center: + return [child, spacer, sheet]; + case _ContextMenuLocation.right: + return orientation == Orientation.portrait ? [child, spacer, sheet] : [sheet, spacer, child]; + case _ContextMenuLocation.left: + return [child, spacer, sheet]; + } + } + + // Build the animation for the _ContextMenuSheet. + Widget _buildSheetAnimation(BuildContext context, Widget? child) { + return Transform.scale( + alignment: _ContextMenuRoute.getSheetAlignment(widget.contextMenuLocation), + scale: _sheetScaleAnimation.value, + child: FadeTransition( + opacity: _sheetOpacityAnimation, + child: child, + ), + ); + } + + // Build the animation for the child. + Widget _buildChildAnimation(BuildContext context, Widget? child) { + _lastScale = _getScale( + widget.orientation, + MediaQuery.of(context).size.height, + _moveAnimation.value.dy, + ); + return Transform.scale( + key: widget.childGlobalKey, + scale: _lastScale, + child: child, + ); + } + + // Build the animation for the overall draggable dismissible content. + Widget _buildAnimation(BuildContext context, Widget? child) { + return Transform.translate( + offset: _moveAnimation.value, + child: child, + ); + } + + @override + void initState() { + super.initState(); + _moveController = AnimationController( + duration: _kMoveControllerDuration, + value: 1.0, + vsync: this, + ); + _sheetController = AnimationController( + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 300), + vsync: this, + ); + _sheetScaleAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate( + CurvedAnimation( + parent: _sheetController, + curve: Curves.linear, + reverseCurve: Curves.easeInBack, + ), + ); + _sheetOpacityAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate(_sheetController); + _setDragOffset(Offset.zero); + } + + @override + void dispose() { + _moveController.dispose(); + _sheetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List children = _getChildren( + widget.orientation, + widget.contextMenuLocation, + ); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(_kPadding), + child: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onPanEnd: _onPanEnd, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + child: AnimatedBuilder( + animation: _moveController, + builder: _buildAnimation, + child: widget.orientation == Orientation.portrait + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ), + ), + ), + ); + } +} + +// The menu that displays when CupertinoContextMenu is open. It consists of a +// list of actions that are typically CupertinoContextMenuActions. +class _ContextMenuSheet extends StatelessWidget { + _ContextMenuSheet({ + Key? key, + required this.actions, + required _ContextMenuLocation contextMenuLocation, + required Orientation orientation, + }) : assert(actions != null && actions.isNotEmpty), + assert(contextMenuLocation != null), + assert(orientation != null), + _contextMenuLocation = contextMenuLocation, + _orientation = orientation, + super(key: key); + + final List actions; + final _ContextMenuLocation _contextMenuLocation; + final Orientation _orientation; + + // Get the children, whose order depends on orientation and + // contextMenuLocation. + List get children { + final Flexible menu = Flexible( + fit: FlexFit.tight, + flex: 2, + child: IntrinsicHeight( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(13.0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: actions, + ), + ), + ), + ); + + switch (_contextMenuLocation) { + case _ContextMenuLocation.center: + return _orientation == Orientation.portrait + ? [ + const Spacer(), + menu, + const Spacer(), + ] + : [ + menu, + const Spacer(), + ]; + case _ContextMenuLocation.right: + return [ + const Spacer(), + menu, + ]; + case _ContextMenuLocation.left: + return [ + menu, + const Spacer(), + ]; + } + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } +} + +// An animation that switches between two colors. +// +// The transition is immediate, so there are no intermediate values or +// interpolation. The color switches from offColor to onColor and back to +// offColor at the times given by intervalOn and intervalOff. +class _OnOffAnimation extends CompoundAnimation { + _OnOffAnimation({ + required AnimationController controller, + required T onValue, + required T offValue, + required double intervalOn, + required double intervalOff, + }) : _offValue = offValue, + assert(intervalOn >= 0.0 && intervalOn <= 1.0), + assert(intervalOff >= 0.0 && intervalOff <= 1.0), + assert(intervalOn <= intervalOff), + super( + first: Tween(begin: offValue, end: onValue).animate( + CurvedAnimation( + parent: controller, + curve: Interval(intervalOn, intervalOn), + ), + ), + next: Tween(begin: onValue, end: offValue).animate( + CurvedAnimation( + parent: controller, + curve: Interval(intervalOff, intervalOff), + ), + ), + ); + + final T _offValue; + + @override + T get value => next.value == _offValue ? next.value : first.value; +} diff --git a/lib/json.jc.dart b/lib/json.jc.dart index 1fcfb3d..a5331fc 100644 --- a/lib/json.jc.dart +++ b/lib/json.jc.dart @@ -9,7 +9,6 @@ import 'package:qinglong_app/module/others/login_log/login_log_bean.dart'; import 'package:qinglong_app/module/others/scripts/script_bean.dart'; import 'package:qinglong_app/module/others/task_log/task_log_bean.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; class JsonConversion$Json { @@ -61,11 +60,7 @@ class JsonConversion$Json { if(type == (TaskBean).toString()){ return TaskBean.jsonConversion(json) as M; } - - if(type == (TaskDetailBean).toString()){ - return TaskDetailBean.jsonConversion(json) as M; - } - + throw Exception("not found"); } @@ -105,11 +100,7 @@ class JsonConversion$Json { if([] is M){ return data.map((e) => TaskBean.jsonConversion(e)).toList() as M; } - - if([] is M){ - return data.map((e) => TaskDetailBean.jsonConversion(e)).toList() as M; - } - + throw Exception("not found"); } } diff --git a/lib/module/others/dependencies/add_dependency_page.dart b/lib/module/others/dependencies/add_dependency_page.dart index 40b8508..76412b4 100644 --- a/lib/module/others/dependencies/add_dependency_page.dart +++ b/lib/module/others/dependencies/add_dependency_page.dart @@ -4,9 +4,6 @@ import 'package:qinglong_app/base/http/api.dart'; import 'package:qinglong_app/base/http/http.dart'; import 'package:qinglong_app/base/ql_app_bar.dart'; import 'package:qinglong_app/module/others/dependencies/dependency_viewmodel.dart'; -import 'package:qinglong_app/module/task/task_bean.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; -import 'package:qinglong_app/module/task/task_viewmodel.dart'; import 'package:qinglong_app/utils/extension.dart'; class AddDependenyPage extends ConsumerStatefulWidget { diff --git a/lib/module/task/add_task_page.dart b/lib/module/task/add_task_page.dart index d5a5c3e..3fc6f24 100644 --- a/lib/module/task/add_task_page.dart +++ b/lib/module/task/add_task_page.dart @@ -5,7 +5,6 @@ import 'package:qinglong_app/base/http/api.dart'; import 'package:qinglong_app/base/http/http.dart'; import 'package:qinglong_app/base/ql_app_bar.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; import 'package:qinglong_app/module/task/task_viewmodel.dart'; import 'package:qinglong_app/utils/extension.dart'; @@ -202,7 +201,7 @@ class _AddTaskPageState extends ConsumerState { taskBean.name = _nameController.text; taskBean.command = _commandController.text; taskBean.schedule = _cronController.text; - HttpResponse response = await Api.addTask( + HttpResponse response = await Api.addTask( _nameController.text, _commandController.text, _cronController.text, id: taskBean.sId); diff --git a/lib/module/task/task_detail/task_detail_bean.dart b/lib/module/task/task_detail/task_detail_bean.dart deleted file mode 100644 index e648c15..0000000 --- a/lib/module/task/task_detail/task_detail_bean.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:json_conversion_annotation/json_conversion_annotation.dart'; - -@JsonConversion() -class TaskDetailBean { - String? name; - String? command; - String? schedule; - bool? saved; - String? sId; - int? created; - int? status; - String? timestamp; - int? isSystem; - int? isDisabled; - String? logPath; - int? isPinned; - - TaskDetailBean( - {this.name, - this.command, - this.schedule, - this.saved, - this.sId, - this.created, - this.status, - this.timestamp, - this.isSystem, - this.isDisabled, - this.logPath, - this.isPinned}); - - TaskDetailBean.fromJson(Map json) { - name = json['name']; - command = json['command']; - schedule = json['schedule']; - saved = json['saved']; - sId = json['_id']; - created = json['created']; - status = json['status']; - timestamp = json['timestamp']; - isSystem = json['isSystem']; - isDisabled = json['isDisabled']; - logPath = json['log_path']; - isPinned = json['isPinned']; - } - - Map toJson() { - final Map data = new Map(); - data['name'] = this.name; - data['command'] = this.command; - data['schedule'] = this.schedule; - data['saved'] = this.saved; - data['_id'] = this.sId; - data['created'] = this.created; - data['status'] = this.status; - data['timestamp'] = this.timestamp; - data['isSystem'] = this.isSystem; - data['isDisabled'] = this.isDisabled; - data['log_path'] = this.logPath; - data['isPinned'] = this.isPinned; - return data; - } - - static TaskDetailBean jsonConversion(Map json) { - return TaskDetailBean.fromJson(json); - } -} diff --git a/lib/module/task/task_detail/task_detail_page.dart b/lib/module/task/task_detail/task_detail_page.dart index 7e5a81b..1b146b7 100644 --- a/lib/module/task/task_detail/task_detail_page.dart +++ b/lib/module/task/task_detail/task_detail_page.dart @@ -1,9 +1,11 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qinglong_app/base/base_state_widget.dart'; import 'package:qinglong_app/base/ql_app_bar.dart'; +import 'package:qinglong_app/base/theme.dart'; +import 'package:qinglong_app/module/task/intime_log/intime_log_page.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_viewmodel.dart'; +import 'package:qinglong_app/utils/utils.dart'; class TaskDetailPage extends ConsumerStatefulWidget { final TaskBean taskBean; @@ -20,17 +22,181 @@ class _TaskDetailPageState extends ConsumerState { return Scaffold( appBar: QlAppBar( canBack: true, + backCall: () { + Navigator.of(context).pop(); + }, title: widget.taskBean.name ?? "", ), - body: BaseStateWidget( - model: taskDetailProvider, - builder: (WidgetRef context, TaskDetailViewModel value, Widget? child) { - return Container(); - }, - onReady: (model) { - model.loadDetail(widget.taskBean.sId!); - }, + body: SingleChildScrollView( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 10, + ), + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + ), + decoration: BoxDecoration( + color: ref.watch(themeProvider).themeColor.settingBgColor(), + borderRadius: BorderRadius.circular(15), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TaskDetailCell( + title: "ID", + desc: widget.taskBean.sId ?? "", + ), + TaskDetailCell( + title: "任务", + desc: widget.taskBean.command ?? "", + ), + TaskDetailCell( + title: "创建时间", + desc: Utils.formatMessageTime(widget.taskBean.created ?? 0), + ), + TaskDetailCell( + title: "任务定时", + desc: widget.taskBean.schedule ?? "", + ), + TaskDetailCell( + title: "最后运行时间", + desc: Utils.formatMessageTime(widget.taskBean.lastExecutionTime ?? 0), + ), + TaskDetailCell( + title: "最后运行时长", + desc: widget.taskBean.lastRunningTime == null ? "-" : "${widget.taskBean.lastRunningTime ?? "-"}秒", + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + showLog(); + }, + child: TaskDetailCell( + title: "日志路径", + desc: widget.taskBean.logPath ?? "-", + taped: () { + showLog(); + }, + ), + ), + TaskDetailCell( + title: "运行状态", + desc: widget.taskBean.status == 0 ? "正在运行" : "空闲", + ), + TaskDetailCell( + title: "脚本状态", + desc: widget.taskBean.isDisabled == 1 ? "已禁用" : "正常", + hideDivide: true, + ), + ], + ), + ), ), ); } + + void showLog() { + showCupertinoDialog( + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: Text( + "${widget.taskBean.name}运行日志", + maxLines: 1, + style: const TextStyle(overflow: TextOverflow.ellipsis), + ), + content: InTimeLogPage(widget.taskBean.sId!, widget.taskBean.status == 0), + actions: [ + CupertinoDialogAction( + child: Text( + "知道了", + style: TextStyle(color: Theme.of(context).primaryColor), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + context: context); + } +} + +class TaskDetailCell extends ConsumerWidget { + final String title; + final String? desc; + final Widget? icon; + final bool hideDivide; + final Function? taped; + + const TaskDetailCell({ + Key? key, + required this.title, + this.desc, + this.icon, + this.hideDivide = false, + this.taped, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + left: 15, + right: 10, + bottom: 10, + ), + child: Row( + children: [ + Text( + title, + style: TextStyle( + color: ref.watch(themeProvider).themeColor.titleColor(), + fontSize: 16, + ), + ), + const SizedBox( + width: 30, + ), + desc != null + ? Expanded( + child: Align( + alignment: Alignment.centerRight, + child: SelectableText( + desc!, + textAlign: TextAlign.right, + onTap: () { + if (taped != null) { + taped!(); + } + }, + style: TextStyle( + color: ref.watch(themeProvider).themeColor.descColor(), + fontSize: 14, + ), + ), + ), + ) + : Expanded( + child: Align(alignment: Alignment.centerRight, child: icon!), + ), + ], + ), + ), + hideDivide + ? const SizedBox.shrink() + : const Divider( + indent: 15, + ), + ], + ); + } } diff --git a/lib/module/task/task_detail/task_detail_viewmodel.dart b/lib/module/task/task_detail/task_detail_viewmodel.dart deleted file mode 100644 index 69bedbe..0000000 --- a/lib/module/task/task_detail/task_detail_viewmodel.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qinglong_app/base/base_viewmodel.dart'; -import 'package:qinglong_app/base/http/api.dart'; -import 'package:qinglong_app/base/http/http.dart'; -import 'package:qinglong_app/module/task/task_detail/task_detail_bean.dart'; - -var taskDetailProvider = - AutoDisposeChangeNotifierProvider((ref) { - return TaskDetailViewModel(); -}); - -class TaskDetailViewModel extends BaseViewModel { - TaskDetailBean? bean; - - TaskDetailViewModel(); - - Future loadDetail(String id) async { - loading(notify: true); - - HttpResponse response = await Api.taskDetail(id); - - if (response.success) { - bean = response.bean; - success(); - } else { - failed(response.message, notify: true); - } - } -} diff --git a/lib/module/task/task_page.dart b/lib/module/task/task_page.dart index 61af502..7b587fb 100644 --- a/lib/module/task/task_page.dart +++ b/lib/module/task/task_page.dart @@ -9,6 +9,7 @@ import 'package:qinglong_app/base/theme.dart'; import 'package:qinglong_app/base/ui/abs_underline_tabindicator.dart'; import 'package:qinglong_app/base/ui/empty_widget.dart'; import 'package:qinglong_app/base/ui/menu.dart'; +import 'package:qinglong_app/base/ui/ql_context_menu.dart'; import 'package:qinglong_app/module/task/intime_log/intime_log_page.dart'; import 'package:qinglong_app/module/task/task_bean.dart'; import 'package:qinglong_app/module/task/task_viewmodel.dart'; @@ -103,8 +104,7 @@ class _TaskPageState extends State { if (_searchController.text.isEmpty || (item.name?.contains(_searchController.text) ?? false) || (item.command?.contains(_searchController.text) ?? false) || - (item.schedule?.contains(_searchController.text) ?? - false)) { + (item.schedule?.contains(_searchController.text) ?? false)) { return TaskItemCell(item, ref); } else { return const SizedBox.shrink(); @@ -162,21 +162,20 @@ class TaskItemCell extends StatelessWidget { final TaskBean bean; final WidgetRef ref; - const TaskItemCell(this.bean, this.ref, {Key? key}) : super(key: key); + TaskItemCell(this.bean, this.ref, {Key? key}) : super(key: key); @override Widget build(BuildContext context) { return ColoredBox( color: ref.watch(themeProvider).themeColor.settingBgColor(), - child: CupertinoContextMenu( + child: QlCupertinoContextMenu( + bean: bean, actions: [ QLCupertinoContextMenuAction( child: Text( bean.status! == 1 ? "运行" : "停止运行", ), - trailingIcon: bean.status! == 1 - ? CupertinoIcons.memories - : CupertinoIcons.stop_circle, + trailingIcon: bean.status! == 1 ? CupertinoIcons.memories : CupertinoIcons.stop_circle, onPressed: () { Navigator.of(context).pop(); startCron(context, ref); @@ -194,8 +193,7 @@ class TaskItemCell extends StatelessWidget { child: const Text("编辑"), onPressed: () { Navigator.of(context).pop(); - Navigator.of(context) - .pushNamed(Routes.routeAddTask, arguments: bean); + Navigator.of(context).pushNamed(Routes.routeAddTask, arguments: bean); }, trailingIcon: CupertinoIcons.pencil_outline, ), @@ -205,9 +203,7 @@ class TaskItemCell extends StatelessWidget { Navigator.of(context).pop(); pinTask(); }, - trailingIcon: bean.isPinned! == 0 - ? CupertinoIcons.pin - : CupertinoIcons.pin_slash, + trailingIcon: bean.isPinned! == 0 ? CupertinoIcons.pin : CupertinoIcons.pin_slash, ), QLCupertinoContextMenuAction( child: Text(bean.isDisabled! == 0 ? "禁用" : "启用"), @@ -216,9 +212,7 @@ class TaskItemCell extends StatelessWidget { enableTask(); }, isDestructiveAction: true, - trailingIcon: bean.isDisabled! == 0 - ? Icons.dnd_forwardslash - : Icons.check_circle_outline_sharp, + trailingIcon: bean.isDisabled! == 0 ? Icons.dnd_forwardslash : Icons.check_circle_outline_sharp, ), QLCupertinoContextMenuAction( child: const Text("删除"), @@ -242,8 +236,7 @@ class TaskItemCell extends StatelessWidget { maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, - color: - ref.watch(themeProvider).themeColor.titleColor(), + color: ref.watch(themeProvider).themeColor.titleColor(), fontSize: 18, ), ), @@ -267,9 +260,7 @@ class TaskItemCell extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - color: bean.isPinned == 1 - ? ref.watch(themeProvider).themeColor.pinColor() - : Colors.transparent, + color: bean.isPinned == 1 ? ref.watch(themeProvider).themeColor.pinColor() : Colors.transparent, padding: const EdgeInsets.symmetric( horizontal: 15, vertical: 8, @@ -289,10 +280,7 @@ class TaskItemCell extends StatelessWidget { maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, - color: ref - .watch(themeProvider) - .themeColor - .titleColor(), + color: ref.watch(themeProvider).themeColor.titleColor(), fontSize: 18, ), ), @@ -313,16 +301,11 @@ class TaskItemCell extends StatelessWidget { Material( color: Colors.transparent, child: Text( - (bean.lastExecutionTime == null || - bean.lastExecutionTime == 0) - ? "-" - : Utils.formatMessageTime( - bean.lastExecutionTime!), + (bean.lastExecutionTime == null || bean.lastExecutionTime == 0) ? "-" : Utils.formatMessageTime(bean.lastExecutionTime!), maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, - color: - ref.watch(themeProvider).themeColor.descColor(), + color: ref.watch(themeProvider).themeColor.descColor(), fontSize: 12, ), ), @@ -341,8 +324,7 @@ class TaskItemCell extends StatelessWidget { maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, - color: - ref.watch(themeProvider).themeColor.descColor(), + color: ref.watch(themeProvider).themeColor.descColor(), fontSize: 12, ), ), @@ -373,53 +355,11 @@ class TaskItemCell extends StatelessWidget { } startCron(BuildContext context, WidgetRef ref) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text("确认运行"), - content: Text("确认运行定时任务 ${bean.name ?? ""} 吗"), - actions: [ - CupertinoDialogAction( - child: const Text("取消"), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - CupertinoDialogAction( - child: const Text("确定"), - onPressed: () { - Navigator.of(context).pop(); - ref.read(taskProvider).runCrons(bean.sId!); - }, - ), - ], - ), - ); + ref.read(taskProvider).runCrons(bean.sId!); } stopCron(BuildContext context, WidgetRef ref) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text("确认停止"), - content: Text("确认停止定时任务 ${bean.name ?? ""} 吗"), - actions: [ - CupertinoDialogAction( - child: const Text("取消"), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - CupertinoDialogAction( - child: const Text("确定"), - onPressed: () { - Navigator.of(context).pop(); - ref.read(taskProvider).stopCrons(bean.sId!); - }, - ), - ], - ), - ); + ref.read(taskProvider).stopCrons(bean.sId!); } logCron(BuildContext context, WidgetRef ref) { diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index f5b2374..b0435d7 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -71,6 +71,9 @@ class Utils { } static String formatMessageTime(int time) { + if(time == 0){ + return "-"; + } DateTime current = DateTime.now(); DateTime chatTime; if (time.toString().length == 10) {