From 4a0076d97dccc8c7abf0b8535283efc807d8f716 Mon Sep 17 00:00:00 2001 From: Lex Lim Date: Tue, 17 Jan 2023 17:30:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=B7=AF=E7=94=B1=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=94=B6=E8=97=8F=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=9A=84API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api/mw/watch.dart | 23 + lib/api/response/csrf_token.dart | 2 +- lib/api/response/mugenapp.dart | 12 +- lib/api/response/page_info.dart | 4 +- lib/api/response/parse.dart | 10 +- lib/api/response/recent_changes.dart | 4 +- lib/api/response/userinfo.dart | 8 +- lib/api/response/watch.dart | 33 + lib/components/isekai_context_menu.dart | 1264 +++++++++++++++++++++++ lib/components/page_card.dart | 233 ++--- lib/components/page_card.getx.dart | 355 +++++++ lib/components/recent_page_list.dart | 2 +- lib/models/favorite_list.dart | 18 +- lib/pages/about.dart | 1 - lib/pages/discover.dart | 15 +- lib/pages/home.dart | 177 ++-- lib/pages/own_profile.dart | 89 +- lib/pages/search.dart | 3 +- lib/pages/settings/list.dart | 85 ++ lib/pages/tab_page.dart | 56 +- 20 files changed, 2067 insertions(+), 327 deletions(-) create mode 100644 lib/api/mw/watch.dart create mode 100644 lib/api/response/watch.dart create mode 100644 lib/components/isekai_context_menu.dart create mode 100644 lib/components/page_card.getx.dart create mode 100644 lib/pages/settings/list.dart diff --git a/lib/api/mw/watch.dart b/lib/api/mw/watch.dart new file mode 100644 index 0000000..717b4d9 --- /dev/null +++ b/lib/api/mw/watch.dart @@ -0,0 +1,23 @@ +import 'package:isekai_wiki/api/response/watch.dart'; + +import 'mw_api.dart'; + +class MWApiWatch { + static Future>> watchPage(List titles, + {bool unwatch = false}) async { + var query = {"titles": titles.join("|")}; + if (unwatch) { + query["unwatch"] = "1"; + } + + var mwRes = await MWApi.post("watch", params: query, withToken: "watch"); + + var data = WatchActionResponse.fromJson(mwRes.data); + + return mwRes.replaceData(data.watch); + } + + static Future>> unwatchPage(List titles) { + return watchPage(titles, unwatch: true); + } +} diff --git a/lib/api/response/csrf_token.dart b/lib/api/response/csrf_token.dart index f241c1e..5d7228d 100644 --- a/lib/api/response/csrf_token.dart +++ b/lib/api/response/csrf_token.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'csrf_token.freezed.dart'; part 'csrf_token.g.dart'; -@freezed +@Freezed(copyWith: false) class CSRFTokenResponse with _$CSRFTokenResponse { const factory CSRFTokenResponse({required Map tokens}) = _CSRFTokenInfoResponse; diff --git a/lib/api/response/mugenapp.dart b/lib/api/response/mugenapp.dart index 56ba1ea..74bf6c2 100644 --- a/lib/api/response/mugenapp.dart +++ b/lib/api/response/mugenapp.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'mugenapp.freezed.dart'; part 'mugenapp.g.dart'; -@freezed +@Freezed(copyWith: false) class MugenAppStartAuthInfo with _$MugenAppStartAuthInfo { const factory MugenAppStartAuthInfo({ required String loginUrl, @@ -15,16 +15,16 @@ class MugenAppStartAuthInfo with _$MugenAppStartAuthInfo { _$MugenAppStartAuthInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MugenAppStartAuthResponse with _$MugenAppStartAuthResponse { - const factory MugenAppStartAuthResponse( - {required MugenAppStartAuthInfo startauth}) = _MugenAppStartAuthResponse; + const factory MugenAppStartAuthResponse({required MugenAppStartAuthInfo startauth}) = + _MugenAppStartAuthResponse; factory MugenAppStartAuthResponse.fromJson(Map json) => _$MugenAppStartAuthResponseFromJson(json); } -@freezed +@Freezed(copyWith: false) class MugenAppAttemptAuthInfo with _$MugenAppAttemptAuthInfo { const factory MugenAppAttemptAuthInfo({ required String status, @@ -36,7 +36,7 @@ class MugenAppAttemptAuthInfo with _$MugenAppAttemptAuthInfo { _$MugenAppAttemptAuthInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MugenAppAttemptAuthResponse with _$MugenAppAttemptAuthResponse { const factory MugenAppAttemptAuthResponse({ required MugenAppAttemptAuthInfo attemptauth, diff --git a/lib/api/response/page_info.dart b/lib/api/response/page_info.dart index 5f35539..1ec2748 100644 --- a/lib/api/response/page_info.dart +++ b/lib/api/response/page_info.dart @@ -83,14 +83,14 @@ class PageInfo { } } -@freezed +@Freezed(copyWith: false) class PagesResponse with _$PagesResponse { factory PagesResponse({required List pages}) = _PageResponse; factory PagesResponse.fromJson(Map json) => _$PagesResponseFromJson(json); } -@freezed +@Freezed(copyWith: false) class PageImageInfo with _$PageImageInfo { factory PageImageInfo({required String source, int? width, int? height}) = _PageImageInfo; diff --git a/lib/api/response/parse.dart b/lib/api/response/parse.dart index 519a288..45ce588 100644 --- a/lib/api/response/parse.dart +++ b/lib/api/response/parse.dart @@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'parse.freezed.dart'; part 'parse.g.dart'; -@freezed +@Freezed(copyWith: false) class MWParseCategoryInfo with _$MWParseCategoryInfo { factory MWParseCategoryInfo({ required String category, @@ -14,7 +14,7 @@ class MWParseCategoryInfo with _$MWParseCategoryInfo { _$MWParseCategoryInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MWParseLangLinkInfo with _$MWParseLangLinkInfo { factory MWParseLangLinkInfo({ required String lang, @@ -28,7 +28,7 @@ class MWParseLangLinkInfo with _$MWParseLangLinkInfo { _$MWParseLangLinkInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MWParsePageLinkInfo with _$MWParsePageLinkInfo { factory MWParsePageLinkInfo({ required int ns, @@ -40,7 +40,7 @@ class MWParsePageLinkInfo with _$MWParsePageLinkInfo { _$MWParsePageLinkInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MWParseSectionInfo with _$MWParseSectionInfo { factory MWParseSectionInfo({ required int toclevel, @@ -57,7 +57,7 @@ class MWParseSectionInfo with _$MWParseSectionInfo { _$MWParseSectionInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MWParseInfo with _$MWParseInfo { factory MWParseInfo({ required String title, diff --git a/lib/api/response/recent_changes.dart b/lib/api/response/recent_changes.dart index b2a250c..cfedf2f 100644 --- a/lib/api/response/recent_changes.dart +++ b/lib/api/response/recent_changes.dart @@ -5,7 +5,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'recent_changes.freezed.dart'; part 'recent_changes.g.dart'; -@freezed +@Freezed(copyWith: false) class RecentChangesItem with _$RecentChangesItem { factory RecentChangesItem({ String? type, @@ -22,7 +22,7 @@ class RecentChangesItem with _$RecentChangesItem { _$RecentChangesItemFromJson(json); } -@freezed +@Freezed(copyWith: false) class RecentChangesResponse with _$RecentChangesResponse { factory RecentChangesResponse({required List recentchanges}) = _RecentChangesResponse; diff --git a/lib/api/response/userinfo.dart b/lib/api/response/userinfo.dart index 8d0a0d9..86dc846 100644 --- a/lib/api/response/userinfo.dart +++ b/lib/api/response/userinfo.dart @@ -5,7 +5,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'userinfo.freezed.dart'; part 'userinfo.g.dart'; -@freezed +@Freezed(copyWith: false) class UserGroupMembership with _$UserGroupMembership { factory UserGroupMembership({ required String group, @@ -16,7 +16,7 @@ class UserGroupMembership with _$UserGroupMembership { _$UserGroupMembershipFromJson(json); } -@freezed +@Freezed(copyWith: false) class UserAcceptLang with _$UserAcceptLang { factory UserAcceptLang({ required double q, @@ -26,7 +26,7 @@ class UserAcceptLang with _$UserAcceptLang { factory UserAcceptLang.fromJson(Map json) => _$UserAcceptLangFromJson(json); } -@freezed +@Freezed(copyWith: false) class MetaUserInfo with _$MetaUserInfo { factory MetaUserInfo({ required int id, @@ -52,7 +52,7 @@ class MetaUserInfo with _$MetaUserInfo { factory MetaUserInfo.fromJson(Map json) => _$MetaUserInfoFromJson(json); } -@freezed +@Freezed(copyWith: false) class MetaUserInfoResponse with _$MetaUserInfoResponse { factory MetaUserInfoResponse({ required MetaUserInfo userinfo, diff --git a/lib/api/response/watch.dart b/lib/api/response/watch.dart new file mode 100644 index 0000000..78a5800 --- /dev/null +++ b/lib/api/response/watch.dart @@ -0,0 +1,33 @@ +// ignore_for_file: unused_element + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'watch.freezed.dart'; +part 'watch.g.dart'; + +@Freezed(copyWith: false) +class WatchActionResponseList with _$WatchActionResponseList { + WatchActionResponseList._(); + + factory WatchActionResponseList( + {required int ns, + required String title, + String? watch, + String? unwatch}) = _WatchActionResponseList; + + bool get isWatch { + return watch != null; + } + + factory WatchActionResponseList.fromJson(Map json) => + _$WatchActionResponseListFromJson(json); +} + +@Freezed(copyWith: false) +class WatchActionResponse with _$WatchActionResponse { + factory WatchActionResponse({required List watch}) = + _WatchActionResponse; + + factory WatchActionResponse.fromJson(Map json) => + _$WatchActionResponseFromJson(json); +} diff --git a/lib/components/isekai_context_menu.dart b/lib/components/isekai_context_menu.dart new file mode 100644 index 0000000..418c2e3 --- /dev/null +++ b/lib/components/isekai_context_menu.dart @@ -0,0 +1,1264 @@ +// 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/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show kMinFlingVelocity, kLongPressTimeout; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.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; + +const Color _borderColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFA9A9AF), + darkColor: Color(0xFF57585A), +); + +typedef _DismissCallback = void Function( + BuildContext context, + double scale, + double opacity, +); + +// 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; + return Rect.fromPoints( + renderBoxContainer.localToGlobal( + renderBoxContainer.paintBounds.topLeft, + ), + renderBoxContainer.localToGlobal(renderBoxContainer.paintBounds.bottomRight)); +} + +// 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 [CupertinoContextMenu] 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: +/// +/// * +class IsekaiCupertinoContextMenu extends StatefulWidget { + /// Create a context menu. + /// + /// [actions] is required and cannot be null or empty. + /// + /// [child] is required and cannot be null. + IsekaiCupertinoContextMenu({ + super.key, + required this.actions, + required this.child, + this.scaleDown = true, + this.scaleUp = true, + this.showMask = true, + this.onTap, + this.previewBuilder, + }) : assert(actions != null && actions.isNotEmpty), + assert(child != null); + + /// The widget that can be "opened" with the [CupertinoContextMenu]. + /// + /// When the [CupertinoContextMenu] 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 [CupertinoContextMenu] 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; + + /// Should show scale down animate + final bool scaleDown; + + /// Should show scale up animate + final bool scaleUp; + + /// Should show mask animate + final bool showMask; + + final VoidCallback? onTap; + + /// A function that returns an alternative widget to show when the + /// [CupertinoContextMenu] 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( + /// // 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: () {}, + /// ), + /// ], + /// child: FittedBox( + /// fit: BoxFit.cover, + /// child: Image.asset('assets/photo.jpg'), + /// ), + /// ) + /// ``` + /// + /// {@end-tool} + final ContextMenuPreviewBuilder? previewBuilder; + + @override + State createState() => _CupertinoContextMenuState(); +} + +class _CupertinoContextMenuState 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(); + } + if (!_openController.isAnimating && widget.onTap != null) { + // 显示缩放动画 + } + widget.onTap?.call(); + } + + 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); + if (widget.scaleUp) { + _decoyChildEndRect = Rect.fromCenter( + center: childRect.center, + width: childRect.width * _kOpenScale, + height: childRect.height * _kOpenScale, + ); + } else { + _decoyChildEndRect = Rect.fromCenter( + center: childRect.center, + width: childRect.width * (2 - _kOpenScale), + height: childRect.height * (2 - _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, + scaleDown: widget.scaleDown, + scaleUp: widget.scaleUp, + showMask: widget.showMask, + child: widget.child, + ); + }, + ); + Overlay.of(context, rootOverlay: true)!.insert(_lastOverlayEntry!); + _openController.forward(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: 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({ + this.beginRect, + required this.controller, + this.scaleDown = true, + this.scaleUp = true, + this.showMask = true, + this.child, + }); + + final Rect? beginRect; + final AnimationController controller; + final Widget? child; + final bool scaleDown; + final bool scaleUp; + final bool showMask; + + @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 _scale; + + @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, + ); + + double midScale = widget.scaleDown ? (2 - _kOpenScale) : 1; + double endScale = widget.scaleUp ? _kOpenScale : midScale; + + _scale = TweenSequence(>[ + TweenSequenceItem( + tween: + Tween(begin: 1, end: midScale).chain(CurveTween(curve: Curves.easeInOutCubic)), + weight: 1.0, + ), + TweenSequenceItem( + tween: Tween(begin: midScale, end: endScale) + .chain(CurveTween(curve: Curves.easeOutCubic)), + weight: 1.0, + ), + ]).animate(widget.controller); + _scale.addListener(_scaleListener); + } + + // Listen to the _rect animation and vibrate when it reaches the halfway point + // and switches from animating down to up. + void _scaleListener() { + if (widget.controller.value < 0.5) { + return; + } + HapticFeedback.selectionClick(); + _scale.removeListener(_scaleListener); + } + + @override + void dispose() { + _scale.removeListener(_scaleListener); + super.dispose(); + } + + Widget _buildAnimation(BuildContext context, Widget? child) { + final Color color = !widget.showMask || widget.controller.status == AnimationStatus.reverse + ? _masklessColor + : _mask.value; + return Positioned.fromRect( + rect: widget.beginRect!, + child: Transform.scale( + scale: _scale.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, + super.filter, + required Rect previousChildRect, + super.settings, + }) : assert(actions != null && actions.isNotEmpty), + assert(contextMenuLocation != null), + _actions = actions, + _builder = builder, + _contextMenuLocation = contextMenuLocation, + _previousChildRect = previousChildRect; + + // 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({ + this.actions, + required this.child, + this.childGlobalKey, + required this.contextMenuLocation, + this.onDismiss, + required this.orientation, + this.sheetGlobalKey, + }) : assert(contextMenuLocation != null), + assert(orientation != null); + + 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( + clampDouble(endX, -_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({ + super.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; + + final List actions; + final _ContextMenuLocation _contextMenuLocation; + final Orientation _orientation; + + // Get the children, whose order depends on orientation and + // contextMenuLocation. + List getChildren(BuildContext context) { + final Widget 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.first, + for (Widget action in actions.skip(1)) + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: CupertinoDynamicColor.resolve(_borderColor, context), + width: 0.5, + )), + ), + position: DecorationPosition.foreground, + child: action, + ), + ], + ), + ), + ), + ); + + 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: getChildren(context), + ); + } +} + +// 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/components/page_card.dart b/lib/components/page_card.dart index 706fdd4..84b3986 100755 --- a/lib/components/page_card.dart +++ b/lib/components/page_card.dart @@ -5,7 +5,6 @@ import 'package:isekai_wiki/api/response/page_info.dart'; import 'package:isekai_wiki/components/flutter_scale_tap/flutter_scale_tap.dart'; import 'package:isekai_wiki/components/utils.dart'; import 'package:isekai_wiki/pages/article.dart'; -import 'package:isekai_wiki/reactive/reactive.dart'; import 'package:like_button/like_button.dart'; import 'package:pull_down_button/pull_down_button.dart'; import 'package:skeletons/skeletons.dart'; @@ -24,53 +23,59 @@ class PageCardStyles { static const double footerButtonInnerSize = 26; } -class PageCardController extends GetxController { - var isLoading = false.obs; - - var pageInfo = Rx(null); - - var isFavorite = false.obs; +class PageCard extends StatelessWidget { + final bool isLoading; + final PageInfo? pageInfo; + final bool isFavorite; + final AddFavoriteCallback? onSetFavorite; + final PageInfoCallback? onShare; - AddFavoriteCallback? onSetFavorite; - PageInfoCallback? onShare; + const PageCard({ + super.key, + this.isLoading = false, + this.pageInfo, + this.isFavorite = false, + this.onSetFavorite, + this.onShare, + }); Future handleFavoriteClick(bool localIsFavorite) async { - if (pageInfo.value != null && onSetFavorite != null) { - return await onSetFavorite!.call(pageInfo.value!, localIsFavorite, false); + if (pageInfo != null && onSetFavorite != null) { + return await onSetFavorite!.call(pageInfo!, localIsFavorite, false); } else { return false; } } Future handleAddFavoriteMenuItemClick() async { - if (pageInfo.value != null && onSetFavorite != null) { - await onSetFavorite!.call(pageInfo.value!, true, true); + if (pageInfo != null && onSetFavorite != null) { + await onSetFavorite!.call(pageInfo!, true, true); } } Future handleRemoveFavoriteMenuItemClick() async { - if (pageInfo.value != null && onSetFavorite != null) { - await onSetFavorite!.call(pageInfo.value!, false, true); + if (pageInfo != null && onSetFavorite != null) { + await onSetFavorite!.call(pageInfo!, false, true); } } handleShareClick() async { - if (pageInfo.value != null && onShare != null) { - await onShare!.call(pageInfo.value!); + if (pageInfo != null && onShare != null) { + await onShare!.call(pageInfo!); } } void handlePageInfoClick() { - if (pageInfo.value != null) {} + if (pageInfo != null) {} } Future handleCardClick() async { - if (isLoading.value) { + if (isLoading) { return; } - if (pageInfo.value != null) { - var cPageInfo = pageInfo.value!; + if (pageInfo != null) { + var cPageInfo = pageInfo!; await Navigator.of(Get.context!).push( CupertinoPageRoute( builder: (_) => ArticlePage( @@ -86,45 +91,6 @@ class PageCardController extends GetxController { ); } } -} - -class PageCard extends StatefulWidget { - final bool isLoading; - final PageInfo? pageInfo; - final bool isFavorite; - final AddFavoriteCallback? onSetFavorite; - final PageInfoCallback? onShare; - - const PageCard({ - super.key, - this.isLoading = false, - this.pageInfo, - this.isFavorite = false, - this.onSetFavorite, - this.onShare, - }); - - @override - State createState() => _PageCardState(); -} - -class _PageCardState extends ReactiveState { - var c = PageCardController(); - double textScale = 1; - - @override - void initState() { - super.initState(); - } - - @override - void receiveProps() { - c.isLoading.value = widget.isLoading; - c.pageInfo.value = widget.pageInfo; - c.isFavorite.value = widget.isFavorite; - c.onSetFavorite = widget.onSetFavorite; - c.onShare = widget.onShare; - } Widget _actionButtonSkeleton(bool isLoading, Widget child) { return Skeleton( @@ -138,20 +104,24 @@ class _PageCardState extends ReactiveState { } Widget _buildCardHeader(BuildContext context) { + var textScale = MediaQuery.of(context).textScaleFactor; + return Padding( padding: const EdgeInsets.only(bottom: 10), child: Skeleton( - isLoading: c.isLoading.value, + isLoading: isLoading, skeleton: SkeletonLine( style: SkeletonLineStyle( height: (Styles.pageCardTitle.fontSize! + 4) * textScale, randomLength: true), ), - child: Text(c.pageInfo.value?.mainTitle ?? "页面信息丢失", style: Styles.pageCardTitle), + child: Text(pageInfo?.mainTitle ?? "页面信息丢失", style: Styles.pageCardTitle), ), ); } Widget _buildCardBody(BuildContext context) { + var textScale = MediaQuery.of(context).textScaleFactor; + return Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 8), @@ -161,7 +131,7 @@ class _PageCardState extends ReactiveState { children: [ Expanded( flex: 1, - child: c.isLoading.value + child: isLoading ? ClipRect( child: SkeletonParagraph( style: SkeletonParagraphStyle( @@ -173,12 +143,12 @@ class _PageCardState extends ReactiveState { ), ), ) - : Text(c.pageInfo.value?.description ?? "没有简介", + : Text(pageInfo?.description ?? "没有简介", overflow: TextOverflow.fade, style: Styles.pageCardDescription), ), const SizedBox(width: 10), Skeleton( - isLoading: c.isLoading.value, + isLoading: isLoading, skeleton: const SkeletonAvatar( style: SkeletonAvatarStyle(width: 114, height: 114), ), @@ -197,49 +167,45 @@ class _PageCardState extends ReactiveState { crossAxisAlignment: CrossAxisAlignment.center, children: [ // 分类信息 - c.pageInfo.value?.mainCategory != null + pageInfo?.mainCategory != null ? Chip( backgroundColor: const Color.fromARGB(1, 238, 238, 238), - label: Text(c.pageInfo.value!.mainCategory!, - style: const TextStyle(color: Colors.black54)), + label: + Text(pageInfo!.mainCategory!, style: const TextStyle(color: Colors.black54)), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap) : const SizedBox(), - c.pageInfo.value?.mainCategory != null ? const SizedBox(width: 10) : const SizedBox(), + pageInfo?.mainCategory != null ? const SizedBox(width: 10) : const SizedBox(), // 发布日期 Skeleton( - isLoading: c.isLoading.value, + isLoading: isLoading, skeleton: const SkeletonLine( style: SkeletonLineStyle(width: 100), ), child: Text( - c.pageInfo.value?.updatedTime != null - ? Utils.getFriendDate(c.pageInfo.value!.updatedTime!) - : "", + pageInfo?.updatedTime != null ? Utils.getFriendDate(pageInfo!.updatedTime!) : "", style: Styles.pageCardDescription), ), const Spacer(), // 收藏键 - Obx( - () => _actionButtonSkeleton( - c.isLoading.value, - LikeButton( - size: PageCardStyles.footerButtonInnerSize, - isLiked: c.isFavorite.value, - onTap: c.handleFavoriteClick, - likeBuilder: (bool isLiked) { - return Icon( - isLiked ? Icons.favorite : Icons.favorite_border, - color: isLiked ? Colors.red : Colors.grey, - size: PageCardStyles.footerButtonInnerSize, - ); - }, - ), + _actionButtonSkeleton( + isLoading, + LikeButton( + size: PageCardStyles.footerButtonInnerSize, + isLiked: isFavorite, + onTap: handleFavoriteClick, + likeBuilder: (bool isLiked) { + return Icon( + isLiked ? Icons.favorite : Icons.favorite_border, + color: isLiked ? Colors.red : Colors.grey, + size: PageCardStyles.footerButtonInnerSize, + ); + }, ), ), const SizedBox(width: 18), // 菜单键 _actionButtonSkeleton( - c.isLoading.value, + isLoading, SizedBox( height: PageCardStyles.footerButtonSize, width: PageCardStyles.footerButtonSize, @@ -252,39 +218,7 @@ class _PageCardState extends ReactiveState { offset: const Offset(0, 2), ), ), - itemBuilder: (context) => [ - c.isFavorite.value - ? PullDownMenuItem( - title: '取消收藏', - icon: CupertinoIcons.heart_fill, - onTap: c.handleRemoveFavoriteMenuItemClick, - ) - : PullDownMenuItem( - title: '收藏', - icon: CupertinoIcons.heart, - onTap: c.handleRemoveFavoriteMenuItemClick, - ), - const PullDownMenuDivider(), - PullDownMenuItem( - title: '分享', - icon: CupertinoIcons.share, - onTap: c.handleShareClick, - ), - const PullDownMenuDivider.large(), - /* - PullDownMenuItem( - title: '相似推荐', - onTap: () {}, - icon: CupertinoIcons.ellipsis_circle, - ), - const PullDownMenuDivider.large(), - */ - PullDownMenuItem( - title: '页面详情', - onTap: c.handlePageInfoClick, - icon: CupertinoIcons.info_circle, - ), - ], + itemBuilder: _buildMenuItem, position: PullDownMenuPosition.under, buttonBuilder: (context, showMenu) => IconButton( onPressed: showMenu, @@ -304,7 +238,45 @@ class _PageCardState extends ReactiveState { ); } + List _buildMenuItem(BuildContext context) { + return [ + isFavorite + ? PullDownMenuItem( + title: '取消收藏', + icon: CupertinoIcons.heart_fill, + onTap: handleRemoveFavoriteMenuItemClick, + ) + : PullDownMenuItem( + title: '收藏', + icon: CupertinoIcons.heart, + onTap: handleRemoveFavoriteMenuItemClick, + ), + const PullDownMenuDivider(), + PullDownMenuItem( + title: '分享', + icon: CupertinoIcons.share, + onTap: handleShareClick, + ), + const PullDownMenuDivider.large(), + /* + PullDownMenuItem( + title: '相似推荐', + onTap: () {}, + icon: CupertinoIcons.ellipsis_circle, + ), + const PullDownMenuDivider.large(), + */ + PullDownMenuItem( + title: '页面详情', + onTap: handlePageInfoClick, + icon: CupertinoIcons.info_circle, + ), + ]; + } + Widget _buildCard(BuildContext context) { + var textScale = MediaQuery.of(context).textScaleFactor; + return Card( elevation: 4.0, // 圆角 @@ -335,20 +307,13 @@ class _PageCardState extends ReactiveState { } @override - Widget render(BuildContext context) { - textScale = MediaQuery.of(context).textScaleFactor; - + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Obx( - () => ScaleTap( - enableFeedback: c.isLoading.value, - onPressed: c.handleCardClick, - child: GetBuilder( - init: c, - builder: (GetxController c) => _buildCard(context), - ), - ), + child: ScaleTap( + enableFeedback: isLoading, + onPressed: handleCardClick, + child: _buildCard(context), ), ); } diff --git a/lib/components/page_card.getx.dart b/lib/components/page_card.getx.dart new file mode 100644 index 0000000..706fdd4 --- /dev/null +++ b/lib/components/page_card.getx.dart @@ -0,0 +1,355 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:isekai_wiki/api/response/page_info.dart'; +import 'package:isekai_wiki/components/flutter_scale_tap/flutter_scale_tap.dart'; +import 'package:isekai_wiki/components/utils.dart'; +import 'package:isekai_wiki/pages/article.dart'; +import 'package:isekai_wiki/reactive/reactive.dart'; +import 'package:like_button/like_button.dart'; +import 'package:pull_down_button/pull_down_button.dart'; +import 'package:skeletons/skeletons.dart'; + +import '../styles.dart'; + +typedef AddFavoriteCallback = Future Function( + PageInfo pageInfo, bool localIsFavorite, bool showToast); + +typedef PageInfoCallback = Future Function(PageInfo pageInfo); + +class PageCardStyles { + static const double cardInnerHeight = 150; + static const cardInnerPadding = EdgeInsets.only(top: 16, left: 20, right: 20, bottom: 12); + static const double footerButtonSize = 30; + static const double footerButtonInnerSize = 26; +} + +class PageCardController extends GetxController { + var isLoading = false.obs; + + var pageInfo = Rx(null); + + var isFavorite = false.obs; + + AddFavoriteCallback? onSetFavorite; + PageInfoCallback? onShare; + + Future handleFavoriteClick(bool localIsFavorite) async { + if (pageInfo.value != null && onSetFavorite != null) { + return await onSetFavorite!.call(pageInfo.value!, localIsFavorite, false); + } else { + return false; + } + } + + Future handleAddFavoriteMenuItemClick() async { + if (pageInfo.value != null && onSetFavorite != null) { + await onSetFavorite!.call(pageInfo.value!, true, true); + } + } + + Future handleRemoveFavoriteMenuItemClick() async { + if (pageInfo.value != null && onSetFavorite != null) { + await onSetFavorite!.call(pageInfo.value!, false, true); + } + } + + handleShareClick() async { + if (pageInfo.value != null && onShare != null) { + await onShare!.call(pageInfo.value!); + } + } + + void handlePageInfoClick() { + if (pageInfo.value != null) {} + } + + Future handleCardClick() async { + if (isLoading.value) { + return; + } + + if (pageInfo.value != null) { + var cPageInfo = pageInfo.value!; + await Navigator.of(Get.context!).push( + CupertinoPageRoute( + builder: (_) => ArticlePage( + targetPage: cPageInfo.title, + initialArticleData: MinimumArticleData( + title: cPageInfo.mainTitle, + description: cPageInfo.description, + mainCategory: cPageInfo.mainCategory, + updateTime: cPageInfo.updatedTime, + ), + ), + ), + ); + } + } +} + +class PageCard extends StatefulWidget { + final bool isLoading; + final PageInfo? pageInfo; + final bool isFavorite; + final AddFavoriteCallback? onSetFavorite; + final PageInfoCallback? onShare; + + const PageCard({ + super.key, + this.isLoading = false, + this.pageInfo, + this.isFavorite = false, + this.onSetFavorite, + this.onShare, + }); + + @override + State createState() => _PageCardState(); +} + +class _PageCardState extends ReactiveState { + var c = PageCardController(); + double textScale = 1; + + @override + void initState() { + super.initState(); + } + + @override + void receiveProps() { + c.isLoading.value = widget.isLoading; + c.pageInfo.value = widget.pageInfo; + c.isFavorite.value = widget.isFavorite; + c.onSetFavorite = widget.onSetFavorite; + c.onShare = widget.onShare; + } + + Widget _actionButtonSkeleton(bool isLoading, Widget child) { + return Skeleton( + isLoading: isLoading, + skeleton: const SkeletonLine( + style: SkeletonLineStyle( + width: PageCardStyles.footerButtonSize, height: PageCardStyles.footerButtonSize), + ), + child: child, + ); + } + + Widget _buildCardHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Skeleton( + isLoading: c.isLoading.value, + skeleton: SkeletonLine( + style: SkeletonLineStyle( + height: (Styles.pageCardTitle.fontSize! + 4) * textScale, randomLength: true), + ), + child: Text(c.pageInfo.value?.mainTitle ?? "页面信息丢失", style: Styles.pageCardTitle), + ), + ); + } + + Widget _buildCardBody(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: c.isLoading.value + ? ClipRect( + child: SkeletonParagraph( + style: SkeletonParagraphStyle( + lines: 3, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 0), + lineStyle: SkeletonLineStyle( + randomLength: true, + height: Styles.pageCardDescription.fontSize! * textScale), + ), + ), + ) + : Text(c.pageInfo.value?.description ?? "没有简介", + overflow: TextOverflow.fade, style: Styles.pageCardDescription), + ), + const SizedBox(width: 10), + Skeleton( + isLoading: c.isLoading.value, + skeleton: const SkeletonAvatar( + style: SkeletonAvatarStyle(width: 114, height: 114), + ), + child: Container(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCardFooter(BuildContext context) { + return ScaleTapIgnore( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 分类信息 + c.pageInfo.value?.mainCategory != null + ? Chip( + backgroundColor: const Color.fromARGB(1, 238, 238, 238), + label: Text(c.pageInfo.value!.mainCategory!, + style: const TextStyle(color: Colors.black54)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap) + : const SizedBox(), + c.pageInfo.value?.mainCategory != null ? const SizedBox(width: 10) : const SizedBox(), + // 发布日期 + Skeleton( + isLoading: c.isLoading.value, + skeleton: const SkeletonLine( + style: SkeletonLineStyle(width: 100), + ), + child: Text( + c.pageInfo.value?.updatedTime != null + ? Utils.getFriendDate(c.pageInfo.value!.updatedTime!) + : "", + style: Styles.pageCardDescription), + ), + const Spacer(), + // 收藏键 + Obx( + () => _actionButtonSkeleton( + c.isLoading.value, + LikeButton( + size: PageCardStyles.footerButtonInnerSize, + isLiked: c.isFavorite.value, + onTap: c.handleFavoriteClick, + likeBuilder: (bool isLiked) { + return Icon( + isLiked ? Icons.favorite : Icons.favorite_border, + color: isLiked ? Colors.red : Colors.grey, + size: PageCardStyles.footerButtonInnerSize, + ); + }, + ), + ), + ), + const SizedBox(width: 18), + // 菜单键 + _actionButtonSkeleton( + c.isLoading.value, + SizedBox( + height: PageCardStyles.footerButtonSize, + width: PageCardStyles.footerButtonSize, + child: PullDownButton( + routeTheme: PullDownMenuRouteTheme( + endShadow: BoxShadow( + color: Colors.grey.withOpacity(0.6), + spreadRadius: 1, + blurRadius: 20, + offset: const Offset(0, 2), + ), + ), + itemBuilder: (context) => [ + c.isFavorite.value + ? PullDownMenuItem( + title: '取消收藏', + icon: CupertinoIcons.heart_fill, + onTap: c.handleRemoveFavoriteMenuItemClick, + ) + : PullDownMenuItem( + title: '收藏', + icon: CupertinoIcons.heart, + onTap: c.handleRemoveFavoriteMenuItemClick, + ), + const PullDownMenuDivider(), + PullDownMenuItem( + title: '分享', + icon: CupertinoIcons.share, + onTap: c.handleShareClick, + ), + const PullDownMenuDivider.large(), + /* + PullDownMenuItem( + title: '相似推荐', + onTap: () {}, + icon: CupertinoIcons.ellipsis_circle, + ), + const PullDownMenuDivider.large(), + */ + PullDownMenuItem( + title: '页面详情', + onTap: c.handlePageInfoClick, + icon: CupertinoIcons.info_circle, + ), + ], + position: PullDownMenuPosition.under, + buttonBuilder: (context, showMenu) => IconButton( + onPressed: showMenu, + padding: const EdgeInsets.all(0.0), + splashRadius: PageCardStyles.footerButtonInnerSize - 4, + iconSize: PageCardStyles.footerButtonInnerSize, + icon: const Icon( + Icons.more_horiz, + color: Colors.grey, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCard(BuildContext context) { + return Card( + elevation: 4.0, + // 圆角 + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(Styles.isXs ? 0 : 14.0)), + ), + // 抗锯齿 + clipBehavior: Clip.antiAlias, + semanticContainer: false, + child: Padding( + padding: PageCardStyles.cardInnerPadding, + child: SizedBox( + height: PageCardStyles.cardInnerHeight * textScale, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + _buildCardHeader(context), + // 简介、图片 + _buildCardBody(context), + // Footer + _buildCardFooter(context), + ], + ), + ), + ), + ); + } + + @override + Widget render(BuildContext context) { + textScale = MediaQuery.of(context).textScaleFactor; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Obx( + () => ScaleTap( + enableFeedback: c.isLoading.value, + onPressed: c.handleCardClick, + child: GetBuilder( + init: c, + builder: (GetxController c) => _buildCard(context), + ), + ), + ), + ); + } +} diff --git a/lib/components/recent_page_list.dart b/lib/components/recent_page_list.dart index 24be18d..104bf93 100755 --- a/lib/components/recent_page_list.dart +++ b/lib/components/recent_page_list.dart @@ -93,7 +93,7 @@ class RecentPageListController extends GetxController { } hasNextPage.value = rcListRes.continueInfo != null; continueInfo.value = rcListRes.continueInfo ?? {}; - } catch (err, stack) { + } catch (err) { hasNextPage.value = false; if (shouldRefresh) { pageList.clear(); diff --git a/lib/models/favorite_list.dart b/lib/models/favorite_list.dart index a466861..02c6ca0 100644 --- a/lib/models/favorite_list.dart +++ b/lib/models/favorite_list.dart @@ -19,8 +19,7 @@ class FavoriteListController extends GetxController { } } - var newPageIds = - pageIds.where((pageId) => !removeList.contains(pageId)).toList(); + var newPageIds = pageIds.where((pageId) => !removeList.contains(pageId)).toList(); for (var pageId in addList) { if (!newPageIds.contains(pageId)) { newPageIds.add(pageId); @@ -47,16 +46,19 @@ class FavoriteListController extends GetxController { return pageIds.contains(pageInfo.pageid); } - Future setFavorite( - PageInfo pageInfo, bool isFavorite, bool showToast) async { + Future setFavorite(PageInfo pageInfo, bool isFavorite, bool showToast) async { // 如果未登录,则提示需要登录 var uc = Get.find(); if (!uc.isLoggedIn) { - var result = await confirm(Get.overlayContext!, "使用收藏功能需要登录", - title: "提示", positiveText: "登录"); - return false; + alert(Get.overlayContext!, "使用收藏功能需要登录", title: "提示"); + return isFavorite; } - return false; + if (isFavorite) { + // 添加收藏 + } else { + // 删除收藏 + } + return !isFavorite; } } diff --git a/lib/pages/about.dart b/lib/pages/about.dart index 8d5132a..b774ca5 100755 --- a/lib/pages/about.dart +++ b/lib/pages/about.dart @@ -25,7 +25,6 @@ class AboutPage extends StatelessWidget { return IsekaiPageScaffold( navigationBar: const IsekaiNavigationBar( middle: Text('关于'), - previousPageTitle: "我的", ), child: ListView( children: [ diff --git a/lib/pages/discover.dart b/lib/pages/discover.dart index bddf8ac..4661155 100755 --- a/lib/pages/discover.dart +++ b/lib/pages/discover.dart @@ -1,16 +1,19 @@ import 'package:flutter/cupertino.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; class DiscoverTab extends StatelessWidget { const DiscoverTab({super.key}); @override Widget build(BuildContext context) { - return const CustomScrollView( - slivers: [ - CupertinoSliverNavigationBar( - largeTitle: Text('发现'), - ), - ], + return const IsekaiPageScaffold( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar( + largeTitle: Text('发现'), + ), + ], + ), ); } } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 45467ea..a933bc2 100755 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:isekai_wiki/components/isekai_nav_bar.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; import 'package:isekai_wiki/components/recent_page_list.dart'; import 'package:isekai_wiki/models/user.dart'; import 'package:isekai_wiki/pages/tab_page.dart'; @@ -127,106 +128,108 @@ class HomeTab extends StatelessWidget { Widget build(BuildContext context) { final c = Get.put(HomeController()); - return WebSmoothScroll( - controller: c.scrollController, - child: CustomScrollView( + return IsekaiPageScaffold( + child: WebSmoothScroll( controller: c.scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - IsekaiSliverNavigationBar( - leading: _buildSearchIconButton(), - backgroundColor: Styles.themeMainColor, - brightness: Brightness.dark, - largeTitle: const Text('首页', style: TextStyle(color: Styles.themeNavTitleColor)), - border: Border.all(style: BorderStyle.none), - trailing: _buildNotificationIconButton(), + child: CustomScrollView( + controller: c.scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), - SliverPersistentHeader( - delegate: _SliverAppBarDelegate( - minHeight: 48, - maxHeight: 48, - child: Container( - decoration: const BoxDecoration( - color: Styles.themeMainColor, - boxShadow: [ - BoxShadow( - color: Styles.themeMainColor, - blurRadius: 0.0, - spreadRadius: 0.0, - offset: Offset(0, -2), - ), - ], - ), - padding: const EdgeInsets.only(bottom: 10, left: 12, right: 12), - child: CupertinoButton( - color: Colors.white, - padding: const EdgeInsets.all(0), - onPressed: () { - onSearchClick?.call(); - }, - child: Wrap( - spacing: 14, - alignment: WrapAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.all(1), - child: const Icon(CupertinoIcons.search, color: Colors.black54), + slivers: [ + IsekaiSliverNavigationBar( + leading: _buildSearchIconButton(), + backgroundColor: Styles.themeMainColor, + brightness: Brightness.dark, + largeTitle: const Text('首页', style: TextStyle(color: Styles.themeNavTitleColor)), + border: Border.all(style: BorderStyle.none), + trailing: _buildNotificationIconButton(), + ), + SliverPersistentHeader( + delegate: _SliverAppBarDelegate( + minHeight: 48, + maxHeight: 48, + child: Container( + decoration: const BoxDecoration( + color: Styles.themeMainColor, + boxShadow: [ + BoxShadow( + color: Styles.themeMainColor, + blurRadius: 0.0, + spreadRadius: 0.0, + offset: Offset(0, -2), ), - const Text("搜索页面...", - textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)) ], ), + padding: const EdgeInsets.only(bottom: 10, left: 12, right: 12), + child: CupertinoButton( + color: Colors.white, + padding: const EdgeInsets.all(0), + onPressed: () { + onSearchClick?.call(); + }, + child: Wrap( + spacing: 14, + alignment: WrapAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(1), + child: const Icon(CupertinoIcons.search, color: Colors.black54), + ), + const Text("搜索页面...", + textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)) + ], + ), + ), ), ), ), - ), - SliverPersistentHeader( - pinned: true, - delegate: _SliverAppBarDelegate( - minHeight: 40.0, - maxHeight: 40.0, - child: Container( - decoration: BoxDecoration(color: Colors.white, boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.2), - spreadRadius: 2, - blurRadius: 4, - offset: const Offset(0, 2), - ) - ]), - child: Row( - children: [ - SizedBox( - child: TabBar( - isScrollable: true, - controller: c.tabController, - indicatorColor: Styles.themeMainColor, - labelColor: Styles.themeMainColor, - unselectedLabelColor: Colors.black45, - tabs: const [CollapsedTabText('最新'), CollapsedTabText('关注')], - onTap: (int selected) {}, + SliverPersistentHeader( + pinned: true, + delegate: _SliverAppBarDelegate( + minHeight: 40.0, + maxHeight: 40.0, + child: Container( + decoration: BoxDecoration(color: Colors.white, boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + spreadRadius: 2, + blurRadius: 4, + offset: const Offset(0, 2), + ) + ]), + child: Row( + children: [ + SizedBox( + child: TabBar( + isScrollable: true, + controller: c.tabController, + indicatorColor: Styles.themeMainColor, + labelColor: Styles.themeMainColor, + unselectedLabelColor: Colors.black45, + tabs: const [CollapsedTabText('最新'), CollapsedTabText('关注')], + onTap: (int selected) {}, + ), ), - ), - const Expanded(child: Text('')), - ], + const Expanded(child: Text('')), + ], + ), ), ), ), - ), - CupertinoSliverRefreshControl( - onRefresh: c.handleRefresh, - ), - SliverSafeArea( - top: false, - bottom: false, - minimum: const EdgeInsets.only(top: 12, bottom: 12), - sliver: RecentPageList( - scrollController: c.scrollController, + CupertinoSliverRefreshControl( + onRefresh: c.handleRefresh, ), - ), - ], + SliverSafeArea( + top: false, + bottom: false, + minimum: const EdgeInsets.only(top: 12, bottom: 12), + sliver: RecentPageList( + scrollController: c.scrollController, + ), + ), + ], + ), ), ); } diff --git a/lib/pages/own_profile.dart b/lib/pages/own_profile.dart index 8cf027b..73e81db 100755 --- a/lib/pages/own_profile.dart +++ b/lib/pages/own_profile.dart @@ -2,11 +2,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:cupertino_lists/cupertino_lists.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_web_browser/flutter_web_browser.dart'; import 'package:get/get.dart'; import 'package:isekai_wiki/components/isekai_nav_bar.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; import 'package:isekai_wiki/models/user.dart'; import 'package:isekai_wiki/pages/about.dart'; +import 'package:isekai_wiki/pages/settings/list.dart'; import 'package:isekai_wiki/styles.dart'; import 'package:isekai_wiki/utils/dialog.dart'; @@ -52,6 +53,24 @@ class OwnProfileController extends GetxController { Future handleLogout() async { await uc.logout(); } + + Future handleSettingsClick(BuildContext context) async { + await Navigator.of(context, rootNavigator: false).push( + CupertinoPageRoute( + title: "设置", + builder: (_) => const SettingsListPage(), + ), + ); + } + + Future handleAboutClick(BuildContext context) async { + await Navigator.of(context, rootNavigator: false).push( + CupertinoPageRoute( + title: "关于", + builder: (_) => const AboutPage(), + ), + ); + } } class OwnProfileTab extends StatelessWidget { @@ -83,8 +102,7 @@ class OwnProfileTab extends StatelessWidget { }); } - Widget _buildUserSection(BuildContext context) { - var c = Get.find(); + Widget _buildUserSection(BuildContext context, OwnProfileController c) { var uc = Get.find(); return FollowTextScale( @@ -138,7 +156,7 @@ class OwnProfileTab extends StatelessWidget { ); } - Widget _buildArticleListsSection(BuildContext context) { + Widget _buildArticleListsSection(BuildContext context, OwnProfileController c) { return FollowTextScale( child: CupertinoListSection.insetGrouped( backgroundColor: Styles.themePageBackgroundColor, @@ -174,7 +192,7 @@ class OwnProfileTab extends StatelessWidget { )); } - Widget _buildSettingsSection(BuildContext context) { + Widget _buildSettingsSection(BuildContext context, OwnProfileController c) { return FollowTextScale( child: CupertinoListSection.insetGrouped( backgroundColor: Styles.themePageBackgroundColor, @@ -186,7 +204,9 @@ class OwnProfileTab extends StatelessWidget { icon: CupertinoIcons.settings, ), trailing: const CupertinoListTileChevron(), - onTap: () {}, + onTap: () async { + await c.handleSettingsClick(context); + }, ), CupertinoListTile.notched( title: const Text('关于'), @@ -196,11 +216,7 @@ class OwnProfileTab extends StatelessWidget { ), trailing: const CupertinoListTileChevron(), onTap: () async { - await Navigator.of(context, rootNavigator: false).push( - CupertinoPageRoute( - builder: (_) => const AboutPage(), - ), - ); + await c.handleAboutClick(context); }, ), if (kDebugMode) @@ -217,41 +233,34 @@ class OwnProfileTab extends StatelessWidget { )); } - SliverChildBuilderDelegate _buildSliverChildBuilderDelegate(BuildContext context) { - return SliverChildBuilderDelegate( - (context, index) { - switch (index) { - case 0: - return _buildUserSection(context); - case 1: - return _buildArticleListsSection(context); - case 2: - return _buildSettingsSection(context); - default: - // Do nothing. For now. - } - return null; - }, - ); + SliverChildListDelegate _buildSliverChildBuilderDelegate( + BuildContext context, OwnProfileController c) { + return SliverChildListDelegate([ + _buildUserSection(context, c), + _buildArticleListsSection(context, c), + _buildSettingsSection(context, c), + ]); } @override Widget build(BuildContext context) { - Get.put(OwnProfileController()); + var c = Get.put(OwnProfileController()); - return CustomScrollView( - slivers: [ - const IsekaiSliverNavigationBar( - largeTitle: Text('我的'), - ), - SliverSafeArea( - top: false, - minimum: const EdgeInsets.only(top: 4), - sliver: SliverList( - delegate: _buildSliverChildBuilderDelegate(context), + return IsekaiPageScaffold( + child: CustomScrollView( + slivers: [ + const IsekaiSliverNavigationBar( + largeTitle: Text('我的'), ), - ) - ], + SliverSafeArea( + top: false, + minimum: const EdgeInsets.only(top: 4), + sliver: SliverList( + delegate: _buildSliverChildBuilderDelegate(context, c), + ), + ) + ], + ), ); } } diff --git a/lib/pages/search.dart b/lib/pages/search.dart index a67f9c5..067ea05 100755 --- a/lib/pages/search.dart +++ b/lib/pages/search.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; import 'package:isekai_wiki/components/state_test.dart'; import 'package:isekai_wiki/styles.dart'; @@ -15,7 +16,7 @@ class SearchTab extends StatelessWidget { Widget build(BuildContext context) { var c = Get.put(SearchController()); - return CupertinoPageScaffold( + return IsekaiPageScaffold( navigationBar: const CupertinoNavigationBar( middle: Text('搜索'), ), diff --git a/lib/pages/settings/list.dart b/lib/pages/settings/list.dart new file mode 100644 index 0000000..0e9298a --- /dev/null +++ b/lib/pages/settings/list.dart @@ -0,0 +1,85 @@ +import 'package:cupertino_lists/cupertino_lists.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:isekai_wiki/components/dummy_icon.dart'; +import 'package:isekai_wiki/components/follow_scale.dart'; +import 'package:isekai_wiki/components/isekai_nav_bar.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; +import 'package:isekai_wiki/global.dart'; +import 'package:isekai_wiki/reactive/reactive.dart'; +import 'package:isekai_wiki/styles.dart'; + +class SettingsListController extends GetxController { + VoidFutureCallback? onSelectionChange; +} + +class SettingsList extends StatefulWidget { + final String selected; + final VoidFutureCallback? onSelectionChange; + + const SettingsList({super.key, this.selected = "", this.onSelectionChange}); + + @override + State createState() { + return _SettingsListState(); + } +} + +class _SettingsListState extends ReactiveState { + var c = SettingsListController(); + + @override + void receiveProps() { + c.onSelectionChange = widget.onSelectionChange; + } + + @override + Widget build(BuildContext context) { + return FollowTextScale( + child: CupertinoListSection.insetGrouped( + backgroundColor: Styles.themePageBackgroundColor, + children: [ + CupertinoListTile.notched( + title: const Text('阅读设置'), + leading: const DummyIcon( + color: CupertinoColors.systemGrey, + icon: CupertinoIcons.textformat, + ), + trailing: const CupertinoListTileChevron(), + onTap: () {}, + ), + ], + ), + ); + } +} + +class SettingsListPage extends StatelessWidget { + const SettingsListPage({super.key}); + + SliverChildListDelegate _buildSliverChildBuilderDelegate(BuildContext context) { + return SliverChildListDelegate([ + const SettingsList(), + ]); + } + + @override + Widget build(BuildContext context) { + return IsekaiPageScaffold( + child: CustomScrollView( + slivers: [ + const IsekaiSliverNavigationBar( + largeTitle: Text('设置'), + ), + SliverSafeArea( + top: false, + minimum: const EdgeInsets.only(top: 4), + sliver: SliverList( + delegate: _buildSliverChildBuilderDelegate(context), + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/tab_page.dart b/lib/pages/tab_page.dart index ebcd2ef..7a02ac4 100755 --- a/lib/pages/tab_page.dart +++ b/lib/pages/tab_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:isekai_wiki/components/isekai_page_scaffold.dart'; import 'package:isekai_wiki/pages/discover.dart'; import 'package:isekai_wiki/pages/home.dart'; import 'package:isekai_wiki/pages/search.dart'; @@ -60,8 +61,7 @@ class IsekaiWikiTabsPage extends StatelessWidget { tabBar: CupertinoTabBar( backgroundColor: Styles.themeBottomColor, activeColor: Styles.themeMainColor, - border: const Border( - top: BorderSide(color: CupertinoColors.systemGrey5, width: 2)), + border: const Border(top: BorderSide(color: CupertinoColors.systemGrey5, width: 2)), height: 56, onTap: c.handleTapTab, items: const [ @@ -86,37 +86,35 @@ class IsekaiWikiTabsPage extends StatelessWidget { tabBuilder: (context, index) { switch (index) { case 0: - return CupertinoTabView(builder: (context) { - return CupertinoPageScaffold( - child: HomeTab( - onSearchClick: c.toSearchPage, - ), - ); - }); + return CupertinoTabView( + defaultTitle: "首页", + builder: (context) => HomeTab( + onSearchClick: c.toSearchPage, + ), + ); case 1: - return CupertinoTabView(builder: (context) { - return const CupertinoPageScaffold( - child: DiscoverTab(), - ); - }); + return CupertinoTabView( + defaultTitle: "发现", + builder: (context) => const DiscoverTab(), + ); case 2: - return CupertinoTabView(builder: (context) { - return const CupertinoPageScaffold( - child: SearchTab(), - ); - }); + return CupertinoTabView( + defaultTitle: "搜索", + builder: (context) => const SearchTab(), + ); case 3: - return CupertinoTabView(builder: (context) { - return const CupertinoPageScaffold( - child: OwnProfileTab(), - ); - }); + return CupertinoTabView( + defaultTitle: "我的", + builder: (context) => const OwnProfileTab(), + ); } - return CupertinoTabView(builder: (context) { - return const CupertinoPageScaffold( - child: HomeTab(), - ); - }); + + return CupertinoTabView( + defaultTitle: "页面不存在", + builder: (context) => const IsekaiPageScaffold( + child: SizedBox(), + ), + ); }, ), );