feat(ui): add a sidebar, floating dock, snap sheets and drag to go back
Changelog: Sidebar, floating dock and drag to go back
This commit is contained in:
@@ -363,6 +363,24 @@ in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/
|
||||
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
|
||||
second provider write — stay hand-written rather than growing extra parameters on the shared items.
|
||||
|
||||
## Keyboard Insets
|
||||
|
||||
The soft keyboard moves `MediaQuery.viewInsets` on every frame of its animation, so whatever depends on them rebuilds
|
||||
and relays out at frame rate for a few hundred milliseconds. `ThemeManager` wraps the whole app in a `LayoutBuilder`,
|
||||
which runs every one of those rebuilds inside the layout phase; in a profile trace they show up there, not under
|
||||
`BUILD`.
|
||||
|
||||
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
|
||||
(`lib/widgets/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
|
||||
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
|
||||
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
|
||||
the open-container route wrap their page in one; a new page route type does the same.
|
||||
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
|
||||
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
|
||||
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
|
||||
`_BodyPadding` in `lib/pages/home.dart`, the keyboard spacer in `CommonScaffold` and the keyboard watch in
|
||||
`SnapSheet` do.
|
||||
|
||||
## State Management
|
||||
|
||||
Provider files in `lib/providers/`:
|
||||
|
||||
@@ -27,6 +27,9 @@ dialogs, and interaction behavior.
|
||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||
keep scrims from stacking.
|
||||
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
|
||||
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
|
||||
`.agents/architecture.md` has the reasons.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/focus.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -196,7 +197,7 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
actionIconTheme: _actionIconTheme,
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.dark),
|
||||
).withAppShapes,
|
||||
home: child!,
|
||||
home: KeyboardInsetHold(child: child!),
|
||||
);
|
||||
},
|
||||
child: const HomePage(),
|
||||
|
||||
@@ -29,6 +29,7 @@ export 'log_payload.dart';
|
||||
export 'lock.dart';
|
||||
export 'measure.dart';
|
||||
export 'mixin.dart';
|
||||
export 'motion.dart';
|
||||
export 'navigator.dart';
|
||||
export 'network.dart';
|
||||
export 'network_error.dart';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
@@ -34,6 +33,8 @@ final listHeaderPadding = EdgeInsets.only(
|
||||
top: 24.mAp,
|
||||
bottom: 8.mAp,
|
||||
);
|
||||
const pageToolbarHeight = 64.0;
|
||||
const sheetToolbarHeight = 48.0;
|
||||
const sheetAppBarHeight = 68.0;
|
||||
|
||||
const watchExecution = false;
|
||||
@@ -87,6 +88,10 @@ const MMDB = 'GEOIP.metadb';
|
||||
const ASN = 'ASN.mmdb';
|
||||
const GEOIP = 'GEOIP.dat';
|
||||
const GEOSITE = 'GEOSITE.dat';
|
||||
|
||||
/// The macOS sidebar material is the finished look; the Windows accent
|
||||
/// effects need a tint over them to keep the rail readable.
|
||||
final double kSidebarBlurOpacity = system.isMacOS ? 0 : 0.5;
|
||||
final double kHeaderHeight = getWindowHeaderHeight(
|
||||
isDesktop: system.isDesktop,
|
||||
isMacOS: system.isMacOS,
|
||||
@@ -112,11 +117,6 @@ const repository = 'chen08209/FlClash';
|
||||
const maxMobileWidth = 600;
|
||||
const maxLaptopWidth = 840;
|
||||
const defaultTestUrl = 'https://www.gstatic.com/generate_204';
|
||||
final commonFilter = ImageFilter.blur(
|
||||
sigmaX: 5,
|
||||
sigmaY: 5,
|
||||
tileMode: TileMode.clamp,
|
||||
);
|
||||
|
||||
const stringListEquality = ListEquality<String>();
|
||||
const intListEquality = ListEquality<int>();
|
||||
|
||||
+18
-8
@@ -31,14 +31,24 @@ extension BuildContextExtension on BuildContext {
|
||||
}
|
||||
}
|
||||
|
||||
double get sheetTopPadding {
|
||||
final sheetType = SheetProvider.of(this)!.type;
|
||||
if (sheetType == SheetType.bottomSheet) {
|
||||
return sheetAppBarHeight;
|
||||
} else {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
bool get isInBottomSheet =>
|
||||
SheetProvider.of(this)?.type == SheetType.bottomSheet;
|
||||
|
||||
/// How far a page's app bar reaches over the top of its body.
|
||||
///
|
||||
/// Where a page builds the body it hands its scaffold, outside that body,
|
||||
/// this is where the bar will end: a toolbar below the status bar.
|
||||
double get appBarInset =>
|
||||
FloatingBarScope.of(this) ??
|
||||
(isInBottomSheet
|
||||
? sheetAppBarHeight
|
||||
: MediaQuery.paddingOf(this).top + pageToolbarHeight);
|
||||
|
||||
/// Where a scaffold body's content starts: a gap clear of the bar; a bottom
|
||||
/// sheet's header already trails its own gap.
|
||||
double get contentTopPadding =>
|
||||
TopInsetScope.of(this) ??
|
||||
(isInBottomSheet ? sheetAppBarHeight : appBarInset + 12);
|
||||
|
||||
void showNotifier(
|
||||
String text, {
|
||||
|
||||
+40
-27
@@ -17,17 +17,16 @@ class Dialogs {
|
||||
required Widget child,
|
||||
BuildContext? context,
|
||||
bool? dismissible,
|
||||
bool filter = true,
|
||||
}) async {
|
||||
final host = context ?? _context;
|
||||
return showModal<T>(
|
||||
useRootNavigator: false,
|
||||
context: context ?? _context,
|
||||
context: host,
|
||||
configuration: FadeScaleTransitionConfiguration(
|
||||
barrierColor: Colors.black38,
|
||||
barrierColor: host.colorScheme.modalScrim,
|
||||
barrierDismissible: dismissible ?? true,
|
||||
),
|
||||
builder: (_) => child,
|
||||
filter: filter ? commonFilter : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,29 +111,43 @@ class Dialogs {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showDisclaimer() async {
|
||||
return await showCommonDialog<bool>(
|
||||
dismissible: false,
|
||||
child: CommonDialog(
|
||||
title: currentAppLocalizations.disclaimer,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(false);
|
||||
},
|
||||
child: Text(currentAppLocalizations.exit),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(_context).pop<bool>(true);
|
||||
},
|
||||
child: Text(currentAppLocalizations.agree),
|
||||
),
|
||||
],
|
||||
child: Text(currentAppLocalizations.disclaimerDesc),
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
Future<String?> showUrlInput({required String title, String value = ''}) {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return showCommonDialog<String>(
|
||||
child: InputDialog(
|
||||
title: title,
|
||||
value: value,
|
||||
labelText: appLocalizations.url,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return appLocalizations.emptyTip(appLocalizations.value);
|
||||
}
|
||||
if (!value.isUrl) {
|
||||
return appLocalizations.urlTip(appLocalizations.value);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String label, String url})?> showNamedUrlInput({
|
||||
required String title,
|
||||
String label = '',
|
||||
String url = '',
|
||||
FormFieldValidator<String>? labelValidator,
|
||||
FormFieldValidator<String>? urlValidator,
|
||||
}) {
|
||||
return showCommonDialog<({String label, String url})>(
|
||||
child: NamedUrlDialog(
|
||||
title: title,
|
||||
label: label,
|
||||
url: url,
|
||||
labelValidator: labelValidator,
|
||||
urlValidator: urlValidator,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showNotifier(
|
||||
|
||||
+16
-3
@@ -4,16 +4,15 @@ import 'dart:ui' show Size;
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
|
||||
import 'constant.dart';
|
||||
import 'num.dart';
|
||||
|
||||
double getWindowHeaderHeight({required bool isDesktop, required bool isMacOS}) {
|
||||
if (!isDesktop) return 0;
|
||||
return isMacOS ? 28 : 32;
|
||||
}
|
||||
|
||||
/// Windows 11 draws its caption buttons 46 wide over a 32 tall title bar
|
||||
/// and renders every glyph as a 10x10 Segoe Fluent icon.
|
||||
/// Windows 11 draws its caption buttons 46 wide over a 32 tall title bar.
|
||||
const captionButtonWidth = 46.0;
|
||||
const captionGlyphSize = 10.0;
|
||||
|
||||
/// The pin is not a caption button: it keeps the round Material button in a
|
||||
/// square slot the height of the bar, with a regular icon size.
|
||||
@@ -38,6 +37,18 @@ ViewMode getViewMode(double viewWidth) {
|
||||
return ViewMode.desktop;
|
||||
}
|
||||
|
||||
/// An expanded sidebar in the laptop range would leave the content narrower
|
||||
/// than the mobile layout one breakpoint below, so labels wait for desktop.
|
||||
bool canExpandSidebar(ViewMode viewMode) => viewMode == ViewMode.desktop;
|
||||
|
||||
/// Measured on macOS 27, the traffic lights span 9 to 69 from the left edge
|
||||
/// of the hidden 32 tall title bar; macOS 10 keeps its title bar instead.
|
||||
const macOSTrafficLightsArea = Size(78, 32);
|
||||
|
||||
Size windowControlsOverSidebar({required bool isMacOS, required int version}) {
|
||||
return isMacOS && version > 10 ? macOSTrafficLightsArea : Size.zero;
|
||||
}
|
||||
|
||||
int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
final columns = max((viewWidth / 250).ceil(), 2);
|
||||
return switch (proxiesLayout) {
|
||||
@@ -47,6 +58,8 @@ int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
};
|
||||
}
|
||||
|
||||
double get cardSpacing => 12.mAp;
|
||||
|
||||
const profileItemMinWidth = 270.0;
|
||||
|
||||
int getProfilesColumns(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
|
||||
const fadeTabDuration = Duration(milliseconds: 220);
|
||||
const fadeTabCurve = Curves.easeInOut;
|
||||
|
||||
/// Plays [spring] over the first [seconds] of an animation, easing out the
|
||||
/// remaining residual so the curve still ends exactly at 1.
|
||||
class SpringCurve extends Curve {
|
||||
SpringCurve(SpringDescription spring, {required this.seconds})
|
||||
: _simulation = SpringSimulation(spring, 0, 1, 0);
|
||||
|
||||
final double seconds;
|
||||
final SpringSimulation _simulation;
|
||||
|
||||
late final double _residual = 1 - _simulation.x(seconds);
|
||||
|
||||
@override
|
||||
double transformInternal(double t) =>
|
||||
_simulation.x(t * seconds) + _residual * t;
|
||||
}
|
||||
+68
-12
@@ -1,5 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -17,12 +22,37 @@ class BaseNavigator {
|
||||
}
|
||||
}
|
||||
|
||||
// Work a page starts on arrival drops frames while the route still animates.
|
||||
Future<void> whenRouteSettled(BuildContext context) async {
|
||||
final route = ModalRoute.of(context);
|
||||
// HeroController builds a pushed route offstage for its first frame, with
|
||||
// the animation pinned to completed, so it only tells the truth after that.
|
||||
while (route != null && route.offstage && route.isActive) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
final animation = route?.animation;
|
||||
if (animation == null || !animation.isAnimating) {
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
void handleStatus(AnimationStatus status) {
|
||||
if (status.isAnimating) {
|
||||
return;
|
||||
}
|
||||
animation.removeStatusListener(handleStatus);
|
||||
completer.complete();
|
||||
}
|
||||
|
||||
animation.addStatusListener(handleStatus);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
const commonSharedXPageTransitions = SharedAxisPageTransitionsBuilder(
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
fillColor: Colors.transparent,
|
||||
);
|
||||
|
||||
class CommonDesktopRoute<T> extends PageRoute<T> {
|
||||
class CommonDesktopRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
CommonDesktopRoute({required this.builder});
|
||||
@@ -39,11 +69,24 @@ class CommonDesktopRoute<T> extends PageRoute<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final Widget result = builder(context);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: FadeTransition(opacity: animation, child: result),
|
||||
child: KeyboardInsetHold(child: builder(context)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return dragBackDetector(
|
||||
isDragBackActive
|
||||
? dragBackSlide(context, animation, child)
|
||||
: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +100,7 @@ class CommonDesktopRoute<T> extends PageRoute<T> {
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 200);
|
||||
}
|
||||
|
||||
class CommonRoute<T> extends PageRoute<T> {
|
||||
class CommonRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
CommonRoute({required this.builder});
|
||||
@@ -77,17 +120,30 @@ class CommonRoute<T> extends PageRoute<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final Widget result = builder(context);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
fillColor: context.colorScheme.surface,
|
||||
child: result,
|
||||
),
|
||||
child: KeyboardInsetHold(child: builder(context)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return dragBackDetector(
|
||||
isDragBackActive
|
||||
? dragBackSlide(context, animation, child)
|
||||
: SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
fillColor: context.colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -183,7 +183,7 @@ extension MessageLevelExt on MessageLevel {
|
||||
|
||||
enum TrafficUnit { B, KB, MB, GB, TB }
|
||||
|
||||
enum NavigationItemMode { mobile, desktop, more }
|
||||
enum NavigationItemMode { mobile, desktop, more, moreFull }
|
||||
|
||||
enum Network { tcp, udp }
|
||||
|
||||
@@ -647,6 +647,8 @@ extension RuleProviderFormatExt on RuleProviderFormat? {
|
||||
|
||||
enum RestoreStrategy { compatible, override }
|
||||
|
||||
enum TabAnimation { slide, fade }
|
||||
|
||||
enum Language { yaml, javaScript, json }
|
||||
|
||||
enum ScrollPositionCacheKey { tools, profiles, proxiesList, proxiesTabList }
|
||||
|
||||
+152
-43
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
@@ -17,12 +18,17 @@ const _actionMinDuration = Duration(seconds: 6);
|
||||
const _maxBufferedMessages = 8;
|
||||
const _mobileMaxVisibleMessages = 2;
|
||||
const _desktopMaxVisibleMessages = 4;
|
||||
const _messageEnterDuration = Duration(milliseconds: 500);
|
||||
const _messageExitDuration = Duration(milliseconds: 400);
|
||||
const _messageEnterDuration = Duration(milliseconds: 450);
|
||||
const _messageExitDuration = Duration(milliseconds: 300);
|
||||
const _messageCollapseDuration = Duration(milliseconds: 200);
|
||||
const _messageEnterOffset = Offset(0.32, 0);
|
||||
const _messageSwipeOutDuration = Duration(milliseconds: 200);
|
||||
const _messageSwipeBackDuration = Duration(milliseconds: 300);
|
||||
const _messageEnterShift = -16.0;
|
||||
const _messageEnterScale = 0.94;
|
||||
const _swipeDismissFraction = 0.35;
|
||||
const _swipeDismissVelocity = 700.0;
|
||||
const _messageMaxWidth = 500.0;
|
||||
const _messageMinHeight = 54.0;
|
||||
const _messageMinHeight = 52.0;
|
||||
|
||||
class StatusManager extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -331,8 +337,7 @@ class _MessageTransitionState extends State<_MessageTransition>
|
||||
late final AnimationController _controller;
|
||||
late final CurvedAnimation _size;
|
||||
late final CurvedAnimation _opacity;
|
||||
late final CurvedAnimation _slide;
|
||||
late final Animation<Offset> _offset;
|
||||
late final CurvedAnimation _settle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -345,19 +350,18 @@ class _MessageTransitionState extends State<_MessageTransition>
|
||||
_size = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0, 0.8, curve: Easing.emphasizedDecelerate),
|
||||
reverseCurve: const Interval(0, 0.65, curve: Easing.emphasizedAccelerate),
|
||||
reverseCurve: const Interval(0, 0.6, curve: Easing.emphasizedAccelerate),
|
||||
);
|
||||
_opacity = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0, 0.4, curve: Curves.easeOut),
|
||||
curve: const Interval(0, 0.5, curve: Curves.easeOut),
|
||||
reverseCurve: const Interval(0.5, 1, curve: Curves.easeIn),
|
||||
);
|
||||
_slide = CurvedAnimation(
|
||||
_settle = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0, 0.85, curve: Easing.emphasizedDecelerate),
|
||||
reverseCurve: const Interval(0.45, 1, curve: Curves.easeInCubic),
|
||||
curve: Easing.emphasizedDecelerate,
|
||||
reverseCurve: const Interval(0.4, 1, curve: Curves.easeInCubic),
|
||||
);
|
||||
_offset = _slide.drive(Tween(begin: _messageEnterOffset, end: Offset.zero));
|
||||
if (widget.visible) {
|
||||
_controller.forward();
|
||||
return;
|
||||
@@ -397,28 +401,41 @@ class _MessageTransitionState extends State<_MessageTransition>
|
||||
_controller.removeStatusListener(_handleAnimationStatus);
|
||||
_size.dispose();
|
||||
_opacity.dispose();
|
||||
_slide.dispose();
|
||||
_settle.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SlideTransition(
|
||||
position: _offset,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _size,
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
child: FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: IgnorePointer(ignoring: !widget.visible, child: widget.child),
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, child) {
|
||||
final settle = widget.swiped ? 1.0 : _settle.value;
|
||||
// Align rather than SizeTransition: its clip would cut the card's
|
||||
// shadow and the card itself as a swipe carries it out of the row.
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
widthFactor: 1,
|
||||
heightFactor: _size.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _messageEnterShift * (1 - settle)),
|
||||
child: Transform.scale(
|
||||
scale: _messageEnterScale + (1 - _messageEnterScale) * settle,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: IgnorePointer(ignoring: !widget.visible, child: widget.child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageCard extends StatelessWidget {
|
||||
class _MessageCard extends StatefulWidget {
|
||||
const _MessageCard({
|
||||
required this.message,
|
||||
required this.onDismiss,
|
||||
@@ -429,32 +446,124 @@ class _MessageCard extends StatelessWidget {
|
||||
final void Function(String id, {bool swiped}) onDismiss;
|
||||
final void Function(String id, bool dragging) onDragging;
|
||||
|
||||
@override
|
||||
State<_MessageCard> createState() => _MessageCardState();
|
||||
}
|
||||
|
||||
class _MessageCardState extends State<_MessageCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _drag;
|
||||
double _width = 0;
|
||||
bool _leaving = false;
|
||||
|
||||
String get _id => widget.message.id;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drag = AnimationController.unbounded(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_drag.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
if (_leaving) {
|
||||
return;
|
||||
}
|
||||
_width = context.size?.width ?? 0;
|
||||
_drag.stop();
|
||||
widget.onDragging(_id, true);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
if (_leaving) {
|
||||
return;
|
||||
}
|
||||
_drag.value += details.primaryDelta ?? 0;
|
||||
}
|
||||
|
||||
Future<void> _release(double velocity) async {
|
||||
if (_leaving) {
|
||||
return;
|
||||
}
|
||||
final offset = _drag.value;
|
||||
final flung =
|
||||
velocity.abs() >= _swipeDismissVelocity && velocity.sign == offset.sign;
|
||||
if (_width > 0 &&
|
||||
(flung || offset.abs() >= _width * _swipeDismissFraction)) {
|
||||
_leaving = true;
|
||||
await _drag.animateTo(
|
||||
offset.sign * math.max(_width, offset.abs()),
|
||||
duration: _messageSwipeOutDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
if (mounted) {
|
||||
widget.onDismiss(_id, swiped: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
widget.onDragging(_id, false);
|
||||
_drag.animateTo(
|
||||
0,
|
||||
duration: _messageSwipeBackDuration,
|
||||
curve: Easing.emphasizedDecelerate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final message = widget.message;
|
||||
final colorScheme = context.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 2, bottom: 8),
|
||||
child: Dismissible(
|
||||
key: ValueKey(message.id),
|
||||
resizeDuration: null,
|
||||
onUpdate: (details) {
|
||||
onDragging(message.id, details.progress > 0);
|
||||
child: AnimatedBuilder(
|
||||
animation: _drag,
|
||||
builder: (_, child) {
|
||||
final offset = _drag.value;
|
||||
final progress = _width > 0 ? offset.abs() / _width : 0.0;
|
||||
return Transform.translate(
|
||||
offset: Offset(offset, 0),
|
||||
child: Opacity(opacity: 1 - progress.clamp(0.0, 1.0), child: child),
|
||||
);
|
||||
},
|
||||
onDismissed: (_) {
|
||||
onDismiss(message.id, swiped: true);
|
||||
},
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: AppShape.lg,
|
||||
elevation: 6,
|
||||
color: message.level.containerColor(context),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: _messageMinHeight,
|
||||
maxWidth: _messageMaxWidth,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: _handleDragStart,
|
||||
onHorizontalDragUpdate: _handleDragUpdate,
|
||||
onHorizontalDragEnd: (details) {
|
||||
_release(details.primaryVelocity ?? 0);
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
_release(0);
|
||||
},
|
||||
child: Material(
|
||||
color: message.level.containerColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: colorScheme.shadow.withValues(alpha: 0.6),
|
||||
elevation: 3,
|
||||
shape: AppShape.lg.copyWith(
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: _MessageContent(message: message, onDismiss: onDismiss),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: _messageMinHeight,
|
||||
maxWidth: _messageMaxWidth,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
child: _MessageContent(
|
||||
message: message,
|
||||
onDismiss: widget.onDismiss,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -57,15 +57,8 @@ class ThemeManager extends ConsumerWidget {
|
||||
|
||||
globalState.measure = Measure.of(context, textScaleFactor);
|
||||
globalState.theme = CommonTheme.of(context, textScaleFactor);
|
||||
final padding = MediaQuery.of(context).padding;
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(textScaleFactor),
|
||||
padding: padding.copyWith(
|
||||
top: padding.top > height * 0.3 ? 20.0 : padding.top,
|
||||
),
|
||||
),
|
||||
return _AppMediaQuery(
|
||||
textScaler: TextScaler.linear(textScaleFactor),
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
ref
|
||||
@@ -79,3 +72,25 @@ class ThemeManager extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppMediaQuery extends StatelessWidget {
|
||||
const _AppMediaQuery({required this.textScaler, required this.child});
|
||||
|
||||
final TextScaler textScaler;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = MediaQuery.of(context);
|
||||
final padding = data.padding;
|
||||
return MediaQuery(
|
||||
data: data.copyWith(
|
||||
textScaler: textScaler,
|
||||
padding: padding.copyWith(
|
||||
top: padding.top > data.size.height * 0.3 ? 20.0 : padding.top,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -96,6 +96,13 @@ Object? _readSidebarExpanded(Map<dynamic, dynamic> json, String key) {
|
||||
return json.containsKey(key) ? json[key] : json['showLabel'];
|
||||
}
|
||||
|
||||
Object? _readTabAnimation(Map<dynamic, dynamic> json, String key) {
|
||||
if (json.containsKey(key)) {
|
||||
return json[key];
|
||||
}
|
||||
return json['isAnimateToPage'] == false ? TabAnimation.fade.name : null;
|
||||
}
|
||||
|
||||
Object? _readUserAgents(Map<dynamic, dynamic> json, String key) {
|
||||
if (json.containsKey(key)) {
|
||||
return json[key];
|
||||
@@ -126,7 +133,10 @@ abstract class AppSettingProps with _$AppSettingProps {
|
||||
@Default(false) bool openLogs,
|
||||
@Default(true) bool closeConnections,
|
||||
@Default(defaultTestUrl) String testUrl,
|
||||
@Default(true) bool isAnimateToPage,
|
||||
@Default(TabAnimation.slide)
|
||||
@JsonKey(readValue: _readTabAnimation)
|
||||
TabAnimation tabAnimation,
|
||||
@Default(true) bool floatingNavigationBar,
|
||||
@Default(true) bool autoCheckUpdate,
|
||||
@Default(true)
|
||||
@JsonKey(readValue: _readSidebarExpanded)
|
||||
@@ -282,7 +292,7 @@ abstract class ProxiesStyleProps with _$ProxiesStyleProps {
|
||||
@Default(ProxiesIconStyle.filled)
|
||||
@JsonKey(fromJson: proxiesIconStyleSafeFromJson)
|
||||
ProxiesIconStyle iconStyle,
|
||||
@Default(ProxyCardType.expand) ProxyCardType cardType,
|
||||
@Default(ProxyCardType.shrink) ProxyCardType cardType,
|
||||
@Default(false) bool hideTimeoutProxies,
|
||||
}) = _ProxiesStyleProps;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$AppSettingProps {
|
||||
|
||||
String? get locale;@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> get dashboardWidgets; bool get onlyStatisticsProxy; bool get showNotificationStopAction; bool get autoLaunch; bool get silentLaunch; bool get autoRun; bool get openLogs; bool get closeConnections; String get testUrl; bool get isAnimateToPage; bool get autoCheckUpdate;@JsonKey(readValue: _readSidebarExpanded) bool get sidebarExpanded; bool get disclaimerAccepted; bool get crashlyticsTip; bool get crashlytics; bool get minimizeOnExit; bool get hidden; bool get developerMode; RestoreStrategy get restoreStrategy; bool get showTrayTitle; bool get checkCertificate;@JsonKey(readValue: _readUserAgents) List<String> get userAgents; bool get hideIp; bool get editorLineWrap; List<String> get serviceOrder; List<String> get disabledServices; String? get currentService;
|
||||
String? get locale;@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> get dashboardWidgets; bool get onlyStatisticsProxy; bool get showNotificationStopAction; bool get autoLaunch; bool get silentLaunch; bool get autoRun; bool get openLogs; bool get closeConnections; String get testUrl;@JsonKey(readValue: _readTabAnimation) TabAnimation get tabAnimation; bool get floatingNavigationBar; bool get autoCheckUpdate;@JsonKey(readValue: _readSidebarExpanded) bool get sidebarExpanded; bool get disclaimerAccepted; bool get crashlyticsTip; bool get crashlytics; bool get minimizeOnExit; bool get hidden; bool get developerMode; RestoreStrategy get restoreStrategy; bool get showTrayTitle; bool get checkCertificate;@JsonKey(readValue: _readUserAgents) List<String> get userAgents; bool get hideIp; bool get editorLineWrap; List<String> get serviceOrder; List<String> get disabledServices; String? get currentService;
|
||||
/// Create a copy of AppSettingProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -30,20 +30,20 @@ $AppSettingPropsCopyWith<AppSettingProps> get copyWith => _$AppSettingPropsCopyW
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as AppSettingProps;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettingProps&&(identical(other.locale, _this.locale) || other.locale == _this.locale)&&const DeepCollectionEquality().equals(other.dashboardWidgets, _this.dashboardWidgets)&&(identical(other.onlyStatisticsProxy, _this.onlyStatisticsProxy) || other.onlyStatisticsProxy == _this.onlyStatisticsProxy)&&(identical(other.showNotificationStopAction, _this.showNotificationStopAction) || other.showNotificationStopAction == _this.showNotificationStopAction)&&(identical(other.autoLaunch, _this.autoLaunch) || other.autoLaunch == _this.autoLaunch)&&(identical(other.silentLaunch, _this.silentLaunch) || other.silentLaunch == _this.silentLaunch)&&(identical(other.autoRun, _this.autoRun) || other.autoRun == _this.autoRun)&&(identical(other.openLogs, _this.openLogs) || other.openLogs == _this.openLogs)&&(identical(other.closeConnections, _this.closeConnections) || other.closeConnections == _this.closeConnections)&&(identical(other.testUrl, _this.testUrl) || other.testUrl == _this.testUrl)&&(identical(other.isAnimateToPage, _this.isAnimateToPage) || other.isAnimateToPage == _this.isAnimateToPage)&&(identical(other.autoCheckUpdate, _this.autoCheckUpdate) || other.autoCheckUpdate == _this.autoCheckUpdate)&&(identical(other.sidebarExpanded, _this.sidebarExpanded) || other.sidebarExpanded == _this.sidebarExpanded)&&(identical(other.disclaimerAccepted, _this.disclaimerAccepted) || other.disclaimerAccepted == _this.disclaimerAccepted)&&(identical(other.crashlyticsTip, _this.crashlyticsTip) || other.crashlyticsTip == _this.crashlyticsTip)&&(identical(other.crashlytics, _this.crashlytics) || other.crashlytics == _this.crashlytics)&&(identical(other.minimizeOnExit, _this.minimizeOnExit) || other.minimizeOnExit == _this.minimizeOnExit)&&(identical(other.hidden, _this.hidden) || other.hidden == _this.hidden)&&(identical(other.developerMode, _this.developerMode) || other.developerMode == _this.developerMode)&&(identical(other.restoreStrategy, _this.restoreStrategy) || other.restoreStrategy == _this.restoreStrategy)&&(identical(other.showTrayTitle, _this.showTrayTitle) || other.showTrayTitle == _this.showTrayTitle)&&(identical(other.checkCertificate, _this.checkCertificate) || other.checkCertificate == _this.checkCertificate)&&const DeepCollectionEquality().equals(other.userAgents, _this.userAgents)&&(identical(other.hideIp, _this.hideIp) || other.hideIp == _this.hideIp)&&(identical(other.editorLineWrap, _this.editorLineWrap) || other.editorLineWrap == _this.editorLineWrap)&&const DeepCollectionEquality().equals(other.serviceOrder, _this.serviceOrder)&&const DeepCollectionEquality().equals(other.disabledServices, _this.disabledServices)&&(identical(other.currentService, _this.currentService) || other.currentService == _this.currentService));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettingProps&&(identical(other.locale, _this.locale) || other.locale == _this.locale)&&const DeepCollectionEquality().equals(other.dashboardWidgets, _this.dashboardWidgets)&&(identical(other.onlyStatisticsProxy, _this.onlyStatisticsProxy) || other.onlyStatisticsProxy == _this.onlyStatisticsProxy)&&(identical(other.showNotificationStopAction, _this.showNotificationStopAction) || other.showNotificationStopAction == _this.showNotificationStopAction)&&(identical(other.autoLaunch, _this.autoLaunch) || other.autoLaunch == _this.autoLaunch)&&(identical(other.silentLaunch, _this.silentLaunch) || other.silentLaunch == _this.silentLaunch)&&(identical(other.autoRun, _this.autoRun) || other.autoRun == _this.autoRun)&&(identical(other.openLogs, _this.openLogs) || other.openLogs == _this.openLogs)&&(identical(other.closeConnections, _this.closeConnections) || other.closeConnections == _this.closeConnections)&&(identical(other.testUrl, _this.testUrl) || other.testUrl == _this.testUrl)&&(identical(other.tabAnimation, _this.tabAnimation) || other.tabAnimation == _this.tabAnimation)&&(identical(other.floatingNavigationBar, _this.floatingNavigationBar) || other.floatingNavigationBar == _this.floatingNavigationBar)&&(identical(other.autoCheckUpdate, _this.autoCheckUpdate) || other.autoCheckUpdate == _this.autoCheckUpdate)&&(identical(other.sidebarExpanded, _this.sidebarExpanded) || other.sidebarExpanded == _this.sidebarExpanded)&&(identical(other.disclaimerAccepted, _this.disclaimerAccepted) || other.disclaimerAccepted == _this.disclaimerAccepted)&&(identical(other.crashlyticsTip, _this.crashlyticsTip) || other.crashlyticsTip == _this.crashlyticsTip)&&(identical(other.crashlytics, _this.crashlytics) || other.crashlytics == _this.crashlytics)&&(identical(other.minimizeOnExit, _this.minimizeOnExit) || other.minimizeOnExit == _this.minimizeOnExit)&&(identical(other.hidden, _this.hidden) || other.hidden == _this.hidden)&&(identical(other.developerMode, _this.developerMode) || other.developerMode == _this.developerMode)&&(identical(other.restoreStrategy, _this.restoreStrategy) || other.restoreStrategy == _this.restoreStrategy)&&(identical(other.showTrayTitle, _this.showTrayTitle) || other.showTrayTitle == _this.showTrayTitle)&&(identical(other.checkCertificate, _this.checkCertificate) || other.checkCertificate == _this.checkCertificate)&&const DeepCollectionEquality().equals(other.userAgents, _this.userAgents)&&(identical(other.hideIp, _this.hideIp) || other.hideIp == _this.hideIp)&&(identical(other.editorLineWrap, _this.editorLineWrap) || other.editorLineWrap == _this.editorLineWrap)&&const DeepCollectionEquality().equals(other.serviceOrder, _this.serviceOrder)&&const DeepCollectionEquality().equals(other.disabledServices, _this.disabledServices)&&(identical(other.currentService, _this.currentService) || other.currentService == _this.currentService));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as AppSettingProps;
|
||||
return Object.hashAll([runtimeType,_this.locale,const DeepCollectionEquality().hash(_this.dashboardWidgets),_this.onlyStatisticsProxy,_this.showNotificationStopAction,_this.autoLaunch,_this.silentLaunch,_this.autoRun,_this.openLogs,_this.closeConnections,_this.testUrl,_this.isAnimateToPage,_this.autoCheckUpdate,_this.sidebarExpanded,_this.disclaimerAccepted,_this.crashlyticsTip,_this.crashlytics,_this.minimizeOnExit,_this.hidden,_this.developerMode,_this.restoreStrategy,_this.showTrayTitle,_this.checkCertificate,const DeepCollectionEquality().hash(_this.userAgents),_this.hideIp,_this.editorLineWrap,const DeepCollectionEquality().hash(_this.serviceOrder),const DeepCollectionEquality().hash(_this.disabledServices),_this.currentService]);
|
||||
return Object.hashAll([runtimeType,_this.locale,const DeepCollectionEquality().hash(_this.dashboardWidgets),_this.onlyStatisticsProxy,_this.showNotificationStopAction,_this.autoLaunch,_this.silentLaunch,_this.autoRun,_this.openLogs,_this.closeConnections,_this.testUrl,_this.tabAnimation,_this.floatingNavigationBar,_this.autoCheckUpdate,_this.sidebarExpanded,_this.disclaimerAccepted,_this.crashlyticsTip,_this.crashlytics,_this.minimizeOnExit,_this.hidden,_this.developerMode,_this.restoreStrategy,_this.showTrayTitle,_this.checkCertificate,const DeepCollectionEquality().hash(_this.userAgents),_this.hideIp,_this.editorLineWrap,const DeepCollectionEquality().hash(_this.serviceOrder),const DeepCollectionEquality().hash(_this.disabledServices),_this.currentService]);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as AppSettingProps;
|
||||
return 'AppSettingProps(locale: ${_this.locale}, dashboardWidgets: ${_this.dashboardWidgets}, onlyStatisticsProxy: ${_this.onlyStatisticsProxy}, showNotificationStopAction: ${_this.showNotificationStopAction}, autoLaunch: ${_this.autoLaunch}, silentLaunch: ${_this.silentLaunch}, autoRun: ${_this.autoRun}, openLogs: ${_this.openLogs}, closeConnections: ${_this.closeConnections}, testUrl: ${_this.testUrl}, isAnimateToPage: ${_this.isAnimateToPage}, autoCheckUpdate: ${_this.autoCheckUpdate}, sidebarExpanded: ${_this.sidebarExpanded}, disclaimerAccepted: ${_this.disclaimerAccepted}, crashlyticsTip: ${_this.crashlyticsTip}, crashlytics: ${_this.crashlytics}, minimizeOnExit: ${_this.minimizeOnExit}, hidden: ${_this.hidden}, developerMode: ${_this.developerMode}, restoreStrategy: ${_this.restoreStrategy}, showTrayTitle: ${_this.showTrayTitle}, checkCertificate: ${_this.checkCertificate}, userAgents: ${_this.userAgents}, hideIp: ${_this.hideIp}, editorLineWrap: ${_this.editorLineWrap}, serviceOrder: ${_this.serviceOrder}, disabledServices: ${_this.disabledServices}, currentService: ${_this.currentService})';
|
||||
return 'AppSettingProps(locale: ${_this.locale}, dashboardWidgets: ${_this.dashboardWidgets}, onlyStatisticsProxy: ${_this.onlyStatisticsProxy}, showNotificationStopAction: ${_this.showNotificationStopAction}, autoLaunch: ${_this.autoLaunch}, silentLaunch: ${_this.silentLaunch}, autoRun: ${_this.autoRun}, openLogs: ${_this.openLogs}, closeConnections: ${_this.closeConnections}, testUrl: ${_this.testUrl}, tabAnimation: ${_this.tabAnimation}, floatingNavigationBar: ${_this.floatingNavigationBar}, autoCheckUpdate: ${_this.autoCheckUpdate}, sidebarExpanded: ${_this.sidebarExpanded}, disclaimerAccepted: ${_this.disclaimerAccepted}, crashlyticsTip: ${_this.crashlyticsTip}, crashlytics: ${_this.crashlytics}, minimizeOnExit: ${_this.minimizeOnExit}, hidden: ${_this.hidden}, developerMode: ${_this.developerMode}, restoreStrategy: ${_this.restoreStrategy}, showTrayTitle: ${_this.showTrayTitle}, checkCertificate: ${_this.checkCertificate}, userAgents: ${_this.userAgents}, hideIp: ${_this.hideIp}, editorLineWrap: ${_this.editorLineWrap}, serviceOrder: ${_this.serviceOrder}, disabledServices: ${_this.disabledServices}, currentService: ${_this.currentService})';
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ abstract mixin class $AppSettingPropsCopyWith<$Res> {
|
||||
factory $AppSettingPropsCopyWith(AppSettingProps value, $Res Function(AppSettingProps) _then) = _$AppSettingPropsCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? locale,@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, bool isAnimateToPage, bool autoCheckUpdate,@JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate,@JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService
|
||||
String? locale,@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl,@JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, bool floatingNavigationBar, bool autoCheckUpdate,@JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate,@JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ class _$AppSettingPropsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettingProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? locale = freezed,Object? dashboardWidgets = null,Object? onlyStatisticsProxy = null,Object? showNotificationStopAction = null,Object? autoLaunch = null,Object? silentLaunch = null,Object? autoRun = null,Object? openLogs = null,Object? closeConnections = null,Object? testUrl = null,Object? isAnimateToPage = null,Object? autoCheckUpdate = null,Object? sidebarExpanded = null,Object? disclaimerAccepted = null,Object? crashlyticsTip = null,Object? crashlytics = null,Object? minimizeOnExit = null,Object? hidden = null,Object? developerMode = null,Object? restoreStrategy = null,Object? showTrayTitle = null,Object? checkCertificate = null,Object? userAgents = null,Object? hideIp = null,Object? editorLineWrap = null,Object? serviceOrder = null,Object? disabledServices = null,Object? currentService = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? locale = freezed,Object? dashboardWidgets = null,Object? onlyStatisticsProxy = null,Object? showNotificationStopAction = null,Object? autoLaunch = null,Object? silentLaunch = null,Object? autoRun = null,Object? openLogs = null,Object? closeConnections = null,Object? testUrl = null,Object? tabAnimation = null,Object? floatingNavigationBar = null,Object? autoCheckUpdate = null,Object? sidebarExpanded = null,Object? disclaimerAccepted = null,Object? crashlyticsTip = null,Object? crashlytics = null,Object? minimizeOnExit = null,Object? hidden = null,Object? developerMode = null,Object? restoreStrategy = null,Object? showTrayTitle = null,Object? checkCertificate = null,Object? userAgents = null,Object? hideIp = null,Object? editorLineWrap = null,Object? serviceOrder = null,Object? disabledServices = null,Object? currentService = freezed,}) {
|
||||
return _then(AppSettingProps(
|
||||
locale: freezed == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
|
||||
as String?,dashboardWidgets: null == dashboardWidgets ? _self.dashboardWidgets : dashboardWidgets // ignore: cast_nullable_to_non_nullable
|
||||
@@ -83,7 +83,8 @@ as bool,autoRun: null == autoRun ? _self.autoRun : autoRun // ignore: cast_nulla
|
||||
as bool,openLogs: null == openLogs ? _self.openLogs : openLogs // ignore: cast_nullable_to_non_nullable
|
||||
as bool,closeConnections: null == closeConnections ? _self.closeConnections : closeConnections // ignore: cast_nullable_to_non_nullable
|
||||
as bool,testUrl: null == testUrl ? _self.testUrl : testUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,isAnimateToPage: null == isAnimateToPage ? _self.isAnimateToPage : isAnimateToPage // ignore: cast_nullable_to_non_nullable
|
||||
as String,tabAnimation: null == tabAnimation ? _self.tabAnimation : tabAnimation // ignore: cast_nullable_to_non_nullable
|
||||
as TabAnimation,floatingNavigationBar: null == floatingNavigationBar ? _self.floatingNavigationBar : floatingNavigationBar // ignore: cast_nullable_to_non_nullable
|
||||
as bool,autoCheckUpdate: null == autoCheckUpdate ? _self.autoCheckUpdate : autoCheckUpdate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,sidebarExpanded: null == sidebarExpanded ? _self.sidebarExpanded : sidebarExpanded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,disclaimerAccepted: null == disclaimerAccepted ? _self.disclaimerAccepted : disclaimerAccepted // ignore: cast_nullable_to_non_nullable
|
||||
@@ -186,10 +187,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, bool isAnimateToPage, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, @JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, bool floatingNavigationBar, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettingProps() when $default != null:
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.isAnimateToPage,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.tabAnimation,_that.floatingNavigationBar,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -207,10 +208,10 @@ return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_t
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, bool isAnimateToPage, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, @JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, bool floatingNavigationBar, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettingProps():
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.isAnimateToPage,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.tabAnimation,_that.floatingNavigationBar,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -227,10 +228,10 @@ return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_t
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, bool isAnimateToPage, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, @JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, bool floatingNavigationBar, bool autoCheckUpdate, @JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate, @JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettingProps() when $default != null:
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.isAnimateToPage,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_that.showNotificationStopAction,_that.autoLaunch,_that.silentLaunch,_that.autoRun,_that.openLogs,_that.closeConnections,_that.testUrl,_that.tabAnimation,_that.floatingNavigationBar,_that.autoCheckUpdate,_that.sidebarExpanded,_that.disclaimerAccepted,_that.crashlyticsTip,_that.crashlytics,_that.minimizeOnExit,_that.hidden,_that.developerMode,_that.restoreStrategy,_that.showTrayTitle,_that.checkCertificate,_that.userAgents,_that.hideIp,_that.editorLineWrap,_that.serviceOrder,_that.disabledServices,_that.currentService);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -242,7 +243,7 @@ return $default(_that.locale,_that.dashboardWidgets,_that.onlyStatisticsProxy,_t
|
||||
@JsonSerializable()
|
||||
|
||||
class _AppSettingProps implements AppSettingProps {
|
||||
const _AppSettingProps({this.locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets = defaultDashboardWidgets, this.onlyStatisticsProxy = false, this.showNotificationStopAction = true, this.autoLaunch = false, this.silentLaunch = false, this.autoRun = false, this.openLogs = false, this.closeConnections = true, this.testUrl = defaultTestUrl, this.isAnimateToPage = true, this.autoCheckUpdate = true, @JsonKey(readValue: _readSidebarExpanded) this.sidebarExpanded = true, this.disclaimerAccepted = false, this.crashlyticsTip = false, this.crashlytics = false, this.minimizeOnExit = true, this.hidden = false, this.developerMode = false, this.restoreStrategy = RestoreStrategy.compatible, this.showTrayTitle = true, this.checkCertificate = true, @JsonKey(readValue: _readUserAgents) List<String> userAgents = defaultUserAgents, this.hideIp = false, this.editorLineWrap = false, List<String> serviceOrder = const [], List<String> disabledServices = const [], this.currentService}): _dashboardWidgets = dashboardWidgets,_userAgents = userAgents,_serviceOrder = serviceOrder,_disabledServices = disabledServices;
|
||||
const _AppSettingProps({this.locale, @JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets = defaultDashboardWidgets, this.onlyStatisticsProxy = false, this.showNotificationStopAction = true, this.autoLaunch = false, this.silentLaunch = false, this.autoRun = false, this.openLogs = false, this.closeConnections = true, this.testUrl = defaultTestUrl, @JsonKey(readValue: _readTabAnimation) this.tabAnimation = TabAnimation.slide, this.floatingNavigationBar = true, this.autoCheckUpdate = true, @JsonKey(readValue: _readSidebarExpanded) this.sidebarExpanded = true, this.disclaimerAccepted = false, this.crashlyticsTip = false, this.crashlytics = false, this.minimizeOnExit = true, this.hidden = false, this.developerMode = false, this.restoreStrategy = RestoreStrategy.compatible, this.showTrayTitle = true, this.checkCertificate = true, @JsonKey(readValue: _readUserAgents) List<String> userAgents = defaultUserAgents, this.hideIp = false, this.editorLineWrap = false, List<String> serviceOrder = const [], List<String> disabledServices = const [], this.currentService}): _dashboardWidgets = dashboardWidgets,_userAgents = userAgents,_serviceOrder = serviceOrder,_disabledServices = disabledServices;
|
||||
factory _AppSettingProps.fromJson(Map<String, dynamic> json) => _$AppSettingPropsFromJson(json);
|
||||
|
||||
@override final String? locale;
|
||||
@@ -261,7 +262,8 @@ class _AppSettingProps implements AppSettingProps {
|
||||
@override@JsonKey() final bool openLogs;
|
||||
@override@JsonKey() final bool closeConnections;
|
||||
@override@JsonKey() final String testUrl;
|
||||
@override@JsonKey() final bool isAnimateToPage;
|
||||
@override@JsonKey(readValue: _readTabAnimation) final TabAnimation tabAnimation;
|
||||
@override@JsonKey() final bool floatingNavigationBar;
|
||||
@override@JsonKey() final bool autoCheckUpdate;
|
||||
@override@JsonKey(readValue: _readSidebarExpanded) final bool sidebarExpanded;
|
||||
@override@JsonKey() final bool disclaimerAccepted;
|
||||
@@ -311,18 +313,18 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettingProps&&(identical(other.locale, locale) || other.locale == locale)&&const DeepCollectionEquality().equals(other.dashboardWidgets, _dashboardWidgets)&&(identical(other.onlyStatisticsProxy, onlyStatisticsProxy) || other.onlyStatisticsProxy == onlyStatisticsProxy)&&(identical(other.showNotificationStopAction, showNotificationStopAction) || other.showNotificationStopAction == showNotificationStopAction)&&(identical(other.autoLaunch, autoLaunch) || other.autoLaunch == autoLaunch)&&(identical(other.silentLaunch, silentLaunch) || other.silentLaunch == silentLaunch)&&(identical(other.autoRun, autoRun) || other.autoRun == autoRun)&&(identical(other.openLogs, openLogs) || other.openLogs == openLogs)&&(identical(other.closeConnections, closeConnections) || other.closeConnections == closeConnections)&&(identical(other.testUrl, testUrl) || other.testUrl == testUrl)&&(identical(other.isAnimateToPage, isAnimateToPage) || other.isAnimateToPage == isAnimateToPage)&&(identical(other.autoCheckUpdate, autoCheckUpdate) || other.autoCheckUpdate == autoCheckUpdate)&&(identical(other.sidebarExpanded, sidebarExpanded) || other.sidebarExpanded == sidebarExpanded)&&(identical(other.disclaimerAccepted, disclaimerAccepted) || other.disclaimerAccepted == disclaimerAccepted)&&(identical(other.crashlyticsTip, crashlyticsTip) || other.crashlyticsTip == crashlyticsTip)&&(identical(other.crashlytics, crashlytics) || other.crashlytics == crashlytics)&&(identical(other.minimizeOnExit, minimizeOnExit) || other.minimizeOnExit == minimizeOnExit)&&(identical(other.hidden, hidden) || other.hidden == hidden)&&(identical(other.developerMode, developerMode) || other.developerMode == developerMode)&&(identical(other.restoreStrategy, restoreStrategy) || other.restoreStrategy == restoreStrategy)&&(identical(other.showTrayTitle, showTrayTitle) || other.showTrayTitle == showTrayTitle)&&(identical(other.checkCertificate, checkCertificate) || other.checkCertificate == checkCertificate)&&const DeepCollectionEquality().equals(other.userAgents, _userAgents)&&(identical(other.hideIp, hideIp) || other.hideIp == hideIp)&&(identical(other.editorLineWrap, editorLineWrap) || other.editorLineWrap == editorLineWrap)&&const DeepCollectionEquality().equals(other.serviceOrder, _serviceOrder)&&const DeepCollectionEquality().equals(other.disabledServices, _disabledServices)&&(identical(other.currentService, currentService) || other.currentService == currentService));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettingProps&&(identical(other.locale, locale) || other.locale == locale)&&const DeepCollectionEquality().equals(other.dashboardWidgets, _dashboardWidgets)&&(identical(other.onlyStatisticsProxy, onlyStatisticsProxy) || other.onlyStatisticsProxy == onlyStatisticsProxy)&&(identical(other.showNotificationStopAction, showNotificationStopAction) || other.showNotificationStopAction == showNotificationStopAction)&&(identical(other.autoLaunch, autoLaunch) || other.autoLaunch == autoLaunch)&&(identical(other.silentLaunch, silentLaunch) || other.silentLaunch == silentLaunch)&&(identical(other.autoRun, autoRun) || other.autoRun == autoRun)&&(identical(other.openLogs, openLogs) || other.openLogs == openLogs)&&(identical(other.closeConnections, closeConnections) || other.closeConnections == closeConnections)&&(identical(other.testUrl, testUrl) || other.testUrl == testUrl)&&(identical(other.tabAnimation, tabAnimation) || other.tabAnimation == tabAnimation)&&(identical(other.floatingNavigationBar, floatingNavigationBar) || other.floatingNavigationBar == floatingNavigationBar)&&(identical(other.autoCheckUpdate, autoCheckUpdate) || other.autoCheckUpdate == autoCheckUpdate)&&(identical(other.sidebarExpanded, sidebarExpanded) || other.sidebarExpanded == sidebarExpanded)&&(identical(other.disclaimerAccepted, disclaimerAccepted) || other.disclaimerAccepted == disclaimerAccepted)&&(identical(other.crashlyticsTip, crashlyticsTip) || other.crashlyticsTip == crashlyticsTip)&&(identical(other.crashlytics, crashlytics) || other.crashlytics == crashlytics)&&(identical(other.minimizeOnExit, minimizeOnExit) || other.minimizeOnExit == minimizeOnExit)&&(identical(other.hidden, hidden) || other.hidden == hidden)&&(identical(other.developerMode, developerMode) || other.developerMode == developerMode)&&(identical(other.restoreStrategy, restoreStrategy) || other.restoreStrategy == restoreStrategy)&&(identical(other.showTrayTitle, showTrayTitle) || other.showTrayTitle == showTrayTitle)&&(identical(other.checkCertificate, checkCertificate) || other.checkCertificate == checkCertificate)&&const DeepCollectionEquality().equals(other.userAgents, _userAgents)&&(identical(other.hideIp, hideIp) || other.hideIp == hideIp)&&(identical(other.editorLineWrap, editorLineWrap) || other.editorLineWrap == editorLineWrap)&&const DeepCollectionEquality().equals(other.serviceOrder, _serviceOrder)&&const DeepCollectionEquality().equals(other.disabledServices, _disabledServices)&&(identical(other.currentService, currentService) || other.currentService == currentService));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hashAll([runtimeType,locale,const DeepCollectionEquality().hash(_dashboardWidgets),onlyStatisticsProxy,showNotificationStopAction,autoLaunch,silentLaunch,autoRun,openLogs,closeConnections,testUrl,isAnimateToPage,autoCheckUpdate,sidebarExpanded,disclaimerAccepted,crashlyticsTip,crashlytics,minimizeOnExit,hidden,developerMode,restoreStrategy,showTrayTitle,checkCertificate,const DeepCollectionEquality().hash(_userAgents),hideIp,editorLineWrap,const DeepCollectionEquality().hash(_serviceOrder),const DeepCollectionEquality().hash(_disabledServices),currentService]);
|
||||
return Object.hashAll([runtimeType,locale,const DeepCollectionEquality().hash(_dashboardWidgets),onlyStatisticsProxy,showNotificationStopAction,autoLaunch,silentLaunch,autoRun,openLogs,closeConnections,testUrl,tabAnimation,floatingNavigationBar,autoCheckUpdate,sidebarExpanded,disclaimerAccepted,crashlyticsTip,crashlytics,minimizeOnExit,hidden,developerMode,restoreStrategy,showTrayTitle,checkCertificate,const DeepCollectionEquality().hash(_userAgents),hideIp,editorLineWrap,const DeepCollectionEquality().hash(_serviceOrder),const DeepCollectionEquality().hash(_disabledServices),currentService]);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettingProps(locale: $locale, dashboardWidgets: $dashboardWidgets, onlyStatisticsProxy: $onlyStatisticsProxy, showNotificationStopAction: $showNotificationStopAction, autoLaunch: $autoLaunch, silentLaunch: $silentLaunch, autoRun: $autoRun, openLogs: $openLogs, closeConnections: $closeConnections, testUrl: $testUrl, isAnimateToPage: $isAnimateToPage, autoCheckUpdate: $autoCheckUpdate, sidebarExpanded: $sidebarExpanded, disclaimerAccepted: $disclaimerAccepted, crashlyticsTip: $crashlyticsTip, crashlytics: $crashlytics, minimizeOnExit: $minimizeOnExit, hidden: $hidden, developerMode: $developerMode, restoreStrategy: $restoreStrategy, showTrayTitle: $showTrayTitle, checkCertificate: $checkCertificate, userAgents: $userAgents, hideIp: $hideIp, editorLineWrap: $editorLineWrap, serviceOrder: $serviceOrder, disabledServices: $disabledServices, currentService: $currentService)';
|
||||
return 'AppSettingProps(locale: $locale, dashboardWidgets: $dashboardWidgets, onlyStatisticsProxy: $onlyStatisticsProxy, showNotificationStopAction: $showNotificationStopAction, autoLaunch: $autoLaunch, silentLaunch: $silentLaunch, autoRun: $autoRun, openLogs: $openLogs, closeConnections: $closeConnections, testUrl: $testUrl, tabAnimation: $tabAnimation, floatingNavigationBar: $floatingNavigationBar, autoCheckUpdate: $autoCheckUpdate, sidebarExpanded: $sidebarExpanded, disclaimerAccepted: $disclaimerAccepted, crashlyticsTip: $crashlyticsTip, crashlytics: $crashlytics, minimizeOnExit: $minimizeOnExit, hidden: $hidden, developerMode: $developerMode, restoreStrategy: $restoreStrategy, showTrayTitle: $showTrayTitle, checkCertificate: $checkCertificate, userAgents: $userAgents, hideIp: $hideIp, editorLineWrap: $editorLineWrap, serviceOrder: $serviceOrder, disabledServices: $disabledServices, currentService: $currentService)';
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +335,7 @@ abstract mixin class _$AppSettingPropsCopyWith<$Res> implements $AppSettingProps
|
||||
factory _$AppSettingPropsCopyWith(_AppSettingProps value, $Res Function(_AppSettingProps) _then) = __$AppSettingPropsCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? locale,@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl, bool isAnimateToPage, bool autoCheckUpdate,@JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate,@JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService
|
||||
String? locale,@JsonKey(fromJson: dashboardWidgetsSafeFormJson) List<DashboardWidget> dashboardWidgets, bool onlyStatisticsProxy, bool showNotificationStopAction, bool autoLaunch, bool silentLaunch, bool autoRun, bool openLogs, bool closeConnections, String testUrl,@JsonKey(readValue: _readTabAnimation) TabAnimation tabAnimation, bool floatingNavigationBar, bool autoCheckUpdate,@JsonKey(readValue: _readSidebarExpanded) bool sidebarExpanded, bool disclaimerAccepted, bool crashlyticsTip, bool crashlytics, bool minimizeOnExit, bool hidden, bool developerMode, RestoreStrategy restoreStrategy, bool showTrayTitle, bool checkCertificate,@JsonKey(readValue: _readUserAgents) List<String> userAgents, bool hideIp, bool editorLineWrap, List<String> serviceOrder, List<String> disabledServices, String? currentService
|
||||
});
|
||||
|
||||
|
||||
@@ -350,7 +352,7 @@ class __$AppSettingPropsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettingProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? locale = freezed,Object? dashboardWidgets = null,Object? onlyStatisticsProxy = null,Object? showNotificationStopAction = null,Object? autoLaunch = null,Object? silentLaunch = null,Object? autoRun = null,Object? openLogs = null,Object? closeConnections = null,Object? testUrl = null,Object? isAnimateToPage = null,Object? autoCheckUpdate = null,Object? sidebarExpanded = null,Object? disclaimerAccepted = null,Object? crashlyticsTip = null,Object? crashlytics = null,Object? minimizeOnExit = null,Object? hidden = null,Object? developerMode = null,Object? restoreStrategy = null,Object? showTrayTitle = null,Object? checkCertificate = null,Object? userAgents = null,Object? hideIp = null,Object? editorLineWrap = null,Object? serviceOrder = null,Object? disabledServices = null,Object? currentService = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? locale = freezed,Object? dashboardWidgets = null,Object? onlyStatisticsProxy = null,Object? showNotificationStopAction = null,Object? autoLaunch = null,Object? silentLaunch = null,Object? autoRun = null,Object? openLogs = null,Object? closeConnections = null,Object? testUrl = null,Object? tabAnimation = null,Object? floatingNavigationBar = null,Object? autoCheckUpdate = null,Object? sidebarExpanded = null,Object? disclaimerAccepted = null,Object? crashlyticsTip = null,Object? crashlytics = null,Object? minimizeOnExit = null,Object? hidden = null,Object? developerMode = null,Object? restoreStrategy = null,Object? showTrayTitle = null,Object? checkCertificate = null,Object? userAgents = null,Object? hideIp = null,Object? editorLineWrap = null,Object? serviceOrder = null,Object? disabledServices = null,Object? currentService = freezed,}) {
|
||||
return _then(_AppSettingProps(
|
||||
locale: freezed == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
|
||||
as String?,dashboardWidgets: null == dashboardWidgets ? _self._dashboardWidgets : dashboardWidgets // ignore: cast_nullable_to_non_nullable
|
||||
@@ -362,7 +364,8 @@ as bool,autoRun: null == autoRun ? _self.autoRun : autoRun // ignore: cast_nulla
|
||||
as bool,openLogs: null == openLogs ? _self.openLogs : openLogs // ignore: cast_nullable_to_non_nullable
|
||||
as bool,closeConnections: null == closeConnections ? _self.closeConnections : closeConnections // ignore: cast_nullable_to_non_nullable
|
||||
as bool,testUrl: null == testUrl ? _self.testUrl : testUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,isAnimateToPage: null == isAnimateToPage ? _self.isAnimateToPage : isAnimateToPage // ignore: cast_nullable_to_non_nullable
|
||||
as String,tabAnimation: null == tabAnimation ? _self.tabAnimation : tabAnimation // ignore: cast_nullable_to_non_nullable
|
||||
as TabAnimation,floatingNavigationBar: null == floatingNavigationBar ? _self.floatingNavigationBar : floatingNavigationBar // ignore: cast_nullable_to_non_nullable
|
||||
as bool,autoCheckUpdate: null == autoCheckUpdate ? _self.autoCheckUpdate : autoCheckUpdate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,sidebarExpanded: null == sidebarExpanded ? _self.sidebarExpanded : sidebarExpanded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,disclaimerAccepted: null == disclaimerAccepted ? _self.disclaimerAccepted : disclaimerAccepted // ignore: cast_nullable_to_non_nullable
|
||||
@@ -2062,7 +2065,7 @@ return $default(_that.type,_that.sortType,_that.layout,_that.iconStyle,_that.car
|
||||
@JsonSerializable()
|
||||
|
||||
class _ProxiesStyleProps implements ProxiesStyleProps {
|
||||
const _ProxiesStyleProps({this.type = ProxiesType.tab, this.sortType = ProxiesSortType.none, this.layout = ProxiesLayout.standard, @JsonKey(fromJson: proxiesIconStyleSafeFromJson) this.iconStyle = ProxiesIconStyle.filled, this.cardType = ProxyCardType.expand, this.hideTimeoutProxies = false});
|
||||
const _ProxiesStyleProps({this.type = ProxiesType.tab, this.sortType = ProxiesSortType.none, this.layout = ProxiesLayout.standard, @JsonKey(fromJson: proxiesIconStyleSafeFromJson) this.iconStyle = ProxiesIconStyle.filled, this.cardType = ProxyCardType.shrink, this.hideTimeoutProxies = false});
|
||||
factory _ProxiesStyleProps.fromJson(Map<String, dynamic> json) => _$ProxiesStylePropsFromJson(json);
|
||||
|
||||
@override@JsonKey() final ProxiesType type;
|
||||
|
||||
@@ -21,7 +21,13 @@ _AppSettingProps _$AppSettingPropsFromJson(Map<String, dynamic> json) =>
|
||||
openLogs: json['openLogs'] as bool? ?? false,
|
||||
closeConnections: json['closeConnections'] as bool? ?? true,
|
||||
testUrl: json['testUrl'] as String? ?? defaultTestUrl,
|
||||
isAnimateToPage: json['isAnimateToPage'] as bool? ?? true,
|
||||
tabAnimation:
|
||||
$enumDecodeNullable(
|
||||
_$TabAnimationEnumMap,
|
||||
_readTabAnimation(json, 'tabAnimation'),
|
||||
) ??
|
||||
TabAnimation.slide,
|
||||
floatingNavigationBar: json['floatingNavigationBar'] as bool? ?? true,
|
||||
autoCheckUpdate: json['autoCheckUpdate'] as bool? ?? true,
|
||||
sidebarExpanded:
|
||||
_readSidebarExpanded(json, 'sidebarExpanded') as bool? ?? true,
|
||||
@@ -73,7 +79,8 @@ Map<String, dynamic> _$AppSettingPropsToJson(_AppSettingProps instance) =>
|
||||
'openLogs': instance.openLogs,
|
||||
'closeConnections': instance.closeConnections,
|
||||
'testUrl': instance.testUrl,
|
||||
'isAnimateToPage': instance.isAnimateToPage,
|
||||
'tabAnimation': _$TabAnimationEnumMap[instance.tabAnimation]!,
|
||||
'floatingNavigationBar': instance.floatingNavigationBar,
|
||||
'autoCheckUpdate': instance.autoCheckUpdate,
|
||||
'sidebarExpanded': instance.sidebarExpanded,
|
||||
'disclaimerAccepted': instance.disclaimerAccepted,
|
||||
@@ -93,6 +100,11 @@ Map<String, dynamic> _$AppSettingPropsToJson(_AppSettingProps instance) =>
|
||||
'currentService': instance.currentService,
|
||||
};
|
||||
|
||||
const _$TabAnimationEnumMap = {
|
||||
TabAnimation.slide: 'slide',
|
||||
TabAnimation.fade: 'fade',
|
||||
};
|
||||
|
||||
const _$RestoreStrategyEnumMap = {
|
||||
RestoreStrategy.compatible: 'compatible',
|
||||
RestoreStrategy.override: 'override',
|
||||
@@ -267,7 +279,7 @@ _ProxiesStyleProps _$ProxiesStylePropsFromJson(Map<String, dynamic> json) =>
|
||||
: proxiesIconStyleSafeFromJson(json['iconStyle']),
|
||||
cardType:
|
||||
$enumDecodeNullable(_$ProxyCardTypeEnumMap, json['cardType']) ??
|
||||
ProxyCardType.expand,
|
||||
ProxyCardType.shrink,
|
||||
hideTimeoutProxies: json['hideTimeoutProxies'] as bool? ?? false,
|
||||
);
|
||||
|
||||
|
||||
+213
-94
@@ -1,8 +1,10 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/manager/app_manager.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/views/dashboard/widgets/start_button.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -29,6 +31,11 @@ class HomePage extends ConsumerWidget {
|
||||
.watch(currentNavigationItemsStateProvider)
|
||||
.value;
|
||||
final isMobile = ref.watch(isMobileViewProvider);
|
||||
final floating = ref.watch(
|
||||
appSettingProvider.select(
|
||||
(state) => state.floatingNavigationBar,
|
||||
),
|
||||
);
|
||||
return _HomePageView(
|
||||
navigationItems: navigationItems,
|
||||
pageBuilder: (_, index) {
|
||||
@@ -37,6 +44,7 @@ class HomePage extends ConsumerWidget {
|
||||
key: ValueKey(navigationItem.label),
|
||||
item: navigationItem,
|
||||
isMobile: isMobile,
|
||||
docked: isMobile && floating,
|
||||
view: navigationItem.builder(context),
|
||||
);
|
||||
},
|
||||
@@ -63,47 +71,80 @@ class _HomeShell extends ConsumerWidget {
|
||||
final state = ref.watch(navigationStateProvider);
|
||||
final isMobile = state.viewMode == ViewMode.mobile;
|
||||
final navigationItems = state.navigationItems;
|
||||
final floating = ref.watch(
|
||||
appSettingProvider.select((state) => state.floatingNavigationBar),
|
||||
);
|
||||
final hasProfile = ref.watch(
|
||||
profilesProvider.select((profiles) => profiles.isNotEmpty),
|
||||
);
|
||||
final isDashboard = ref.watch(
|
||||
currentPageLabelProvider.select((label) => label == PageLabel.dashboard),
|
||||
);
|
||||
return Material(
|
||||
color: context.colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: FocusTraversalGroup(
|
||||
policy: PageTraversalPolicy(),
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: false,
|
||||
removeBottom: isMobile,
|
||||
removeLeft: isMobile,
|
||||
removeRight: isMobile,
|
||||
context: context,
|
||||
child: child,
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FocusTraversalGroup(
|
||||
policy: PageTraversalPolicy(),
|
||||
child: BottomInsetScope(
|
||||
inset: isMobile && floating
|
||||
? NavigationDock.insetOf(context)
|
||||
: 0,
|
||||
child: _BodyPadding(isMobile: isMobile, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
start: 0,
|
||||
end: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedVisibility.bottomNavigation(
|
||||
visible: isMobile && floating,
|
||||
child: _NavigationPadding(
|
||||
child: NavigationDock(
|
||||
destinations: [
|
||||
for (final item in navigationItems)
|
||||
NavigationDockDestination(
|
||||
glyph: item.glyph,
|
||||
label: item.label.label,
|
||||
),
|
||||
],
|
||||
selectedIndex: state.currentIndex,
|
||||
onSelected: (index) {
|
||||
_handleToPage(navigationItems[index].label, ref);
|
||||
},
|
||||
trailing: hasProfile && isDashboard
|
||||
? const StartButton()
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedVisibility.bottomNavigation(
|
||||
visible: isMobile,
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
removeBottom: false,
|
||||
removeLeft: true,
|
||||
removeRight: true,
|
||||
context: context,
|
||||
child: NavigationBarTheme(
|
||||
data: _NavigationBarDefaultsM3(context),
|
||||
child: NavigationBar(
|
||||
destinations: [
|
||||
for (final item in navigationItems)
|
||||
NavigationDestination(
|
||||
icon: item.icon,
|
||||
label: item.label.label,
|
||||
visible: isMobile && !floating,
|
||||
child: _NavigationPadding(
|
||||
child: NavigationBar(
|
||||
destinations: [
|
||||
for (final (index, item) in navigationItems.indexed)
|
||||
NavigationDestination(
|
||||
icon: AnimatedGlyph(
|
||||
glyph: item.glyph,
|
||||
filled: index == state.currentIndex,
|
||||
),
|
||||
],
|
||||
onDestinationSelected: (index) {
|
||||
_handleToPage(navigationItems[index].label, ref);
|
||||
},
|
||||
selectedIndex: state.currentIndex,
|
||||
),
|
||||
label: item.label.label,
|
||||
),
|
||||
],
|
||||
selectedIndex: state.currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
_handleToPage(navigationItems[index].label, ref);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -113,21 +154,62 @@ class _HomeShell extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _BodyPadding extends StatelessWidget {
|
||||
const _BodyPadding({required this.isMobile, required this.child});
|
||||
|
||||
final bool isMobile;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQuery.removePadding(
|
||||
removeTop: false,
|
||||
removeBottom: isMobile,
|
||||
removeLeft: isMobile,
|
||||
removeRight: isMobile,
|
||||
context: context,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationPadding extends StatelessWidget {
|
||||
const _NavigationPadding({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
removeBottom: false,
|
||||
removeLeft: true,
|
||||
removeRight: true,
|
||||
context: context,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationPage extends StatelessWidget {
|
||||
const _NavigationPage({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isMobile,
|
||||
required this.docked,
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final NavigationItem item;
|
||||
final bool isMobile;
|
||||
final bool docked;
|
||||
final Widget view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scopedView = PageFocusScope(child: view);
|
||||
final scopedView = PageFocusScope(
|
||||
child: DockedPageScope(docked: docked, child: view),
|
||||
);
|
||||
final keptView = KeepScope(
|
||||
key: ValueKey(item.label),
|
||||
keep: item.keep,
|
||||
@@ -144,9 +226,17 @@ class _NavigationPage extends StatelessWidget {
|
||||
final isActive = ref.watch(
|
||||
currentPageLabelProvider.select((label) => label == item.label),
|
||||
);
|
||||
// A kept-alive page off screen still ticks its animations, and
|
||||
// each tick asks for a frame.
|
||||
return PageActivityScope(
|
||||
isActive: isActive,
|
||||
child: ExcludeFocus(excluding: !isActive, child: child!),
|
||||
child: TickerMode(
|
||||
enabled: isActive,
|
||||
child: ExcludeFocus(
|
||||
excluding: !isActive,
|
||||
child: KeyboardInsetHold(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: keptView,
|
||||
@@ -170,6 +260,9 @@ class _HomePageView extends ConsumerStatefulWidget {
|
||||
class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
late PageController _pageController;
|
||||
|
||||
List<int>? _order;
|
||||
int _slide = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -185,6 +278,7 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
void didUpdateWidget(covariant _HomePageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.navigationItems.length != widget.navigationItems.length) {
|
||||
_order = null;
|
||||
_updatePageController();
|
||||
}
|
||||
}
|
||||
@@ -207,16 +301,39 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final isAnimateToPage = ref.read(appSettingProvider).isAnimateToPage;
|
||||
final tabAnimation = ref.read(appSettingProvider).tabAnimation;
|
||||
final isMobile = ref.read(isMobileViewProvider);
|
||||
if (isAnimateToPage && isMobile && !ignoreAnimateTo) {
|
||||
await _pageController.animateToPage(
|
||||
index,
|
||||
duration: kTabScrollDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} else {
|
||||
final slide = ++_slide;
|
||||
final page = _pageController.hasClients
|
||||
? _pageController.page?.round() ?? index
|
||||
: index;
|
||||
final current = _order?[page] ?? page;
|
||||
if (_order != null) {
|
||||
setState(() => _order = null);
|
||||
_pageController.jumpToPage(current);
|
||||
}
|
||||
if (!isMobile || ignoreAnimateTo) {
|
||||
_pageController.jumpToPage(index);
|
||||
return;
|
||||
}
|
||||
// As TabBarView does, so no page between is built and painted on the way.
|
||||
if ((index - current).abs() > 1) {
|
||||
final adjacent = index > current ? index - 1 : index + 1;
|
||||
setState(() {
|
||||
_order = List.generate(widget.navigationItems.length, (item) => item)
|
||||
..[adjacent] = current
|
||||
..[current] = adjacent;
|
||||
});
|
||||
_pageController.jumpToPage(adjacent);
|
||||
}
|
||||
final fade = tabAnimation == TabAnimation.fade;
|
||||
await _pageController.animateToPage(
|
||||
index,
|
||||
duration: fade ? fadeTabDuration : kTabScrollDuration,
|
||||
curve: fade ? fadeTabCurve : Curves.easeOut,
|
||||
);
|
||||
if (mounted && slide == _slide && _order != null) {
|
||||
setState(() => _order = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +353,11 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
final itemCount = ref.watch(
|
||||
currentNavigationItemsStateProvider.select((state) => state.value.length),
|
||||
);
|
||||
final fade = ref.watch(
|
||||
appSettingProvider.select(
|
||||
(state) => state.tabAnimation == TabAnimation.fade,
|
||||
),
|
||||
);
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -247,69 +369,62 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
final index = widget.navigationItems.indexWhere(
|
||||
(item) => item.label == key.value,
|
||||
);
|
||||
return index == -1 ? null : index;
|
||||
if (index == -1) {
|
||||
return null;
|
||||
}
|
||||
return _order?.indexOf(index) ?? index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return widget.pageBuilder(context, index);
|
||||
final page = widget.pageBuilder(context, _order?[index] ?? index);
|
||||
return _FadeTabPage(
|
||||
key: page.key,
|
||||
controller: _pageController,
|
||||
position: index,
|
||||
enabled: fade,
|
||||
child: page,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationBarDefaultsM3 extends NavigationBarThemeData {
|
||||
_NavigationBarDefaultsM3(this.context)
|
||||
: super(
|
||||
height: 80.0,
|
||||
elevation: 3.0,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
);
|
||||
/// Cancels the page view's slide so a tab switch cross-fades in place, and
|
||||
/// wraps every page even when off so switching the setting keeps their state.
|
||||
class _FadeTabPage extends StatelessWidget {
|
||||
const _FadeTabPage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.position,
|
||||
required this.enabled,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final BuildContext context;
|
||||
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
||||
late final TextTheme _textTheme = Theme.of(context).textTheme;
|
||||
final PageController controller;
|
||||
final int position;
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Color? get backgroundColor => _colors.surfaceContainer;
|
||||
|
||||
@override
|
||||
Color? get shadowColor => Colors.transparent;
|
||||
|
||||
@override
|
||||
Color? get surfaceTintColor => Colors.transparent;
|
||||
|
||||
@override
|
||||
WidgetStateProperty<IconThemeData?>? get iconTheme {
|
||||
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||
return IconThemeData(
|
||||
size: 24.0,
|
||||
color: states.contains(WidgetState.disabled)
|
||||
? _colors.onSurfaceVariant.opacity38
|
||||
: states.contains(WidgetState.selected)
|
||||
? _colors.onSecondaryContainer
|
||||
: _colors.onSurfaceVariant,
|
||||
);
|
||||
});
|
||||
double get _delta {
|
||||
if (!controller.hasClients || !controller.position.hasContentDimensions) {
|
||||
return 0;
|
||||
}
|
||||
final page = controller.page ?? position.toDouble();
|
||||
return (position - page).clamp(-1.0, 1.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get indicatorColor => _colors.secondaryContainer;
|
||||
|
||||
@override
|
||||
ShapeBorder? get indicatorShape => AppShape.full;
|
||||
|
||||
@override
|
||||
WidgetStateProperty<TextStyle?>? get labelTextStyle {
|
||||
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
||||
final TextStyle style = _textTheme.labelMedium!;
|
||||
return style.apply(
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: states.contains(WidgetState.disabled)
|
||||
? _colors.onSurfaceVariant.opacity38
|
||||
: states.contains(WidgetState.selected)
|
||||
? _colors.onSurface
|
||||
: _colors.onSurfaceVariant,
|
||||
);
|
||||
});
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (_, child) {
|
||||
final delta = enabled ? _delta : 0.0;
|
||||
return FractionalTranslation(
|
||||
translation: Offset(-delta, 0),
|
||||
child: Opacity(opacity: 1 - delta.abs(), child: child),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +443,10 @@ class HomeBackScopeContainer extends ConsumerWidget {
|
||||
final canPop = Navigator.canPop(realContext);
|
||||
if (canPop) {
|
||||
Navigator.of(realContext).pop();
|
||||
} else if (system.isTV && pageLabel != PageLabel.dashboard) {
|
||||
ref
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.dashboard);
|
||||
} else {
|
||||
await ref.read(systemActionProvider.notifier).handleClose();
|
||||
}
|
||||
|
||||
@@ -70,7 +70,9 @@ MoreToolsSelectorState moreToolsSelectorState(Ref ref) {
|
||||
navigationItemsStateProvider.select((state) {
|
||||
return SelectValue(
|
||||
state.value.where((element) {
|
||||
final isMore = element.modes.contains(NavigationItemMode.more);
|
||||
final isMore =
|
||||
element.modes.contains(NavigationItemMode.more) ||
|
||||
element.modes.contains(NavigationItemMode.moreFull);
|
||||
final isDesktop = element.modes.contains(
|
||||
NavigationItemMode.desktop,
|
||||
);
|
||||
@@ -116,5 +118,5 @@ double overlayTopOffset(Ref ref) {
|
||||
version: version,
|
||||
isMobileView: isMobileView,
|
||||
);
|
||||
return kToolbarHeight + (showsHeader ? kHeaderHeight : 0);
|
||||
return pageToolbarHeight + (showsHeader ? kHeaderHeight : 0);
|
||||
}
|
||||
|
||||
+18
-10
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/common/app_ports.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/app_glyphs.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/views/views.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -15,13 +16,13 @@ class Navigation implements NavigationPort {
|
||||
return [
|
||||
NavigationItem(
|
||||
keep: false,
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) =>
|
||||
const DashboardView(key: GlobalObjectKey(PageLabel.dashboard)),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
glyph: AppGlyphs.proxies,
|
||||
label: PageLabel.proxies,
|
||||
builder: (_) =>
|
||||
const ProxiesView(key: GlobalObjectKey(PageLabel.proxies)),
|
||||
@@ -30,34 +31,41 @@ class Navigation implements NavigationPort {
|
||||
: [],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
glyph: AppGlyphs.profiles,
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) =>
|
||||
const ProfilesView(key: GlobalObjectKey(PageLabel.profiles)),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.view_timeline),
|
||||
glyph: AppGlyphs.requests,
|
||||
label: PageLabel.requests,
|
||||
builder: (_) =>
|
||||
const RequestsView(key: GlobalObjectKey(PageLabel.requests)),
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
|
||||
modes: [NavigationItemMode.moreFull],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.ballot),
|
||||
glyph: AppGlyphs.connections,
|
||||
label: PageLabel.connections,
|
||||
builder: (_) =>
|
||||
const ConnectionsView(key: GlobalObjectKey(PageLabel.connections)),
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.more],
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.moreFull],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.storage),
|
||||
glyph: AppGlyphs.dns,
|
||||
label: PageLabel.dns,
|
||||
builder: (_) =>
|
||||
const DnsQueriesView(key: GlobalObjectKey(PageLabel.dns)),
|
||||
modes: [NavigationItemMode.moreFull],
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.resources,
|
||||
label: PageLabel.resources,
|
||||
builder: (_) =>
|
||||
const ResourcesView(key: GlobalObjectKey(PageLabel.resources)),
|
||||
modes: [NavigationItemMode.more],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.adb),
|
||||
glyph: AppGlyphs.logs,
|
||||
label: PageLabel.logs,
|
||||
builder: (_) => const LogsView(key: GlobalObjectKey(PageLabel.logs)),
|
||||
modes: openLogs
|
||||
@@ -65,7 +73,7 @@ class Navigation implements NavigationPort {
|
||||
: [],
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const ToolsView(key: GlobalObjectKey(PageLabel.tools)),
|
||||
modes: [NavigationItemMode.desktop, NavigationItemMode.mobile],
|
||||
|
||||
+28
-71
@@ -1,22 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/views/about.dart';
|
||||
import 'package:fl_clash/views/access.dart';
|
||||
import 'package:fl_clash/views/application_setting.dart';
|
||||
import 'package:fl_clash/views/backup_and_restore.dart';
|
||||
import 'package:fl_clash/views/config/config.dart';
|
||||
import 'package:fl_clash/views/config/general.dart';
|
||||
import 'package:fl_clash/views/hotkey.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' show dirname, join;
|
||||
|
||||
import 'config/advanced.dart';
|
||||
import 'developer.dart';
|
||||
import 'disclaimer.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
class ToolsView extends ConsumerStatefulWidget {
|
||||
@@ -28,14 +27,11 @@ class ToolsView extends ConsumerStatefulWidget {
|
||||
|
||||
class _ToolViewState extends ConsumerState<ToolsView> {
|
||||
Widget _buildNavigationMenuItem(NavigationItem navigationItem) {
|
||||
final description = navigationItem.label.description;
|
||||
return ListItem.open(
|
||||
leading: navigationItem.icon,
|
||||
leading: GlyphIcon(navigationItem.glyph),
|
||||
title: Text(navigationItem.label.label),
|
||||
subtitle: description != null ? Text(description) : null,
|
||||
widget: navigationItem.builder(context),
|
||||
maxWidth: 400,
|
||||
forceFull: false,
|
||||
forceFull: navigationItem.modes.contains(NavigationItemMode.moreFull),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,11 +67,9 @@ class _ToolViewState extends ConsumerState<ToolsView> {
|
||||
const _ThemeItem(),
|
||||
const _BackupItem(),
|
||||
if (system.isDesktop) const _HotkeyItem(),
|
||||
if (system.isWindows) const _LoopbackItem(),
|
||||
if (system.isAndroid) const _AccessItem(),
|
||||
const _ConfigItem(),
|
||||
const _AdvancedConfigItem(),
|
||||
const _SettingItem(),
|
||||
const _GeneralItem(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -111,7 +105,10 @@ class _ToolViewState extends ConsumerState<ToolsView> {
|
||||
key: toolsStoreKey,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, index) => items[index],
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
padding: EdgeInsets.only(
|
||||
top: context.appBarInset,
|
||||
bottom: 20 + BottomInsetScope.of(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -132,7 +129,7 @@ class _LocaleItem extends ConsumerWidget {
|
||||
);
|
||||
final currentLocale = getLocaleForString(locale);
|
||||
return ListItem<Locale?>.options(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
leading: const GlyphIcon(AppGlyphs.language),
|
||||
title: Text(context.appLocalizations.language),
|
||||
subtitle: Text(_getLocaleString(context, currentLocale)),
|
||||
dialogTitle: context.appLocalizations.language,
|
||||
@@ -154,7 +151,7 @@ class _ThemeItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.style),
|
||||
leading: const GlyphIcon(AppGlyphs.paintbrush),
|
||||
title: Text(context.appLocalizations.theme),
|
||||
subtitle: Text(context.appLocalizations.themeDesc),
|
||||
widget: const ThemeView(),
|
||||
@@ -168,7 +165,7 @@ class _BackupItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.cloud_sync),
|
||||
leading: const GlyphIcon(AppGlyphs.cloudSync),
|
||||
title: Text(context.appLocalizations.backupAndRestore),
|
||||
subtitle: Text(context.appLocalizations.backupAndRestoreDesc),
|
||||
widget: const BackupAndRestore(),
|
||||
@@ -182,40 +179,20 @@ class _HotkeyItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.keyboard),
|
||||
leading: const GlyphIcon(AppGlyphs.keyboard),
|
||||
title: Text(context.appLocalizations.hotkeyManagement),
|
||||
subtitle: Text(context.appLocalizations.hotkeyManagementDesc),
|
||||
widget: const HotKeyView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoopbackItem extends StatelessWidget {
|
||||
const _LoopbackItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem(
|
||||
leading: const Icon(Icons.lock),
|
||||
title: Text(context.appLocalizations.loopback),
|
||||
subtitle: Text(context.appLocalizations.loopbackDesc),
|
||||
onTap: () {
|
||||
windows?.runas(
|
||||
'"${join(dirname(Platform.resolvedExecutable), "EnableLoopback.exe")}"',
|
||||
'',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccessItem extends StatelessWidget {
|
||||
const _AccessItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.view_list),
|
||||
leading: const GlyphIcon(AppGlyphs.appsList),
|
||||
title: Text(context.appLocalizations.accessControl),
|
||||
subtitle: Text(context.appLocalizations.accessControlDesc),
|
||||
widget: const AccessView(),
|
||||
@@ -223,16 +200,15 @@ class _AccessItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfigItem extends StatelessWidget {
|
||||
const _ConfigItem();
|
||||
class _GeneralItem extends StatelessWidget {
|
||||
const _GeneralItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: Text(context.appLocalizations.basicConfig),
|
||||
subtitle: Text(context.appLocalizations.basicConfigDesc),
|
||||
widget: const ConfigView(),
|
||||
leading: const GlyphIcon(AppGlyphs.settings),
|
||||
title: Text(context.appLocalizations.general),
|
||||
widget: const GeneralView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,7 +219,7 @@ class _AdvancedConfigItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.build),
|
||||
leading: const GlyphIcon(AppGlyphs.wrench),
|
||||
title: Text(context.appLocalizations.advancedConfig),
|
||||
subtitle: Text(context.appLocalizations.advancedConfigDesc),
|
||||
widget: const AdvancedConfigView(),
|
||||
@@ -251,34 +227,15 @@ class _AdvancedConfigItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingItem extends StatelessWidget {
|
||||
const _SettingItem();
|
||||
class _DisclaimerItem extends StatelessWidget {
|
||||
const _DisclaimerItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(context.appLocalizations.application),
|
||||
subtitle: Text(context.appLocalizations.applicationDesc),
|
||||
widget: const ApplicationSettingView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisclaimerItem extends ConsumerWidget {
|
||||
const _DisclaimerItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
return ListItem(
|
||||
leading: const Icon(Icons.gavel),
|
||||
leading: const GlyphIcon(AppGlyphs.gavel),
|
||||
title: Text(context.appLocalizations.disclaimer),
|
||||
onTap: () async {
|
||||
final isDisclaimerAccepted = await dialogs.showDisclaimer();
|
||||
if (!isDisclaimerAccepted) {
|
||||
await ref.read(systemActionProvider.notifier).handleExit();
|
||||
}
|
||||
},
|
||||
widget: const DisclaimerView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -289,7 +246,7 @@ class _InfoItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.info),
|
||||
leading: const GlyphIcon(AppGlyphs.info),
|
||||
title: Text(context.appLocalizations.about),
|
||||
widget: const AboutView(),
|
||||
);
|
||||
@@ -302,7 +259,7 @@ class _DeveloperItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListItem.open(
|
||||
leading: const Icon(Icons.developer_board),
|
||||
leading: const GlyphIcon(AppGlyphs.cpu),
|
||||
title: Text(context.appLocalizations.developerMode),
|
||||
widget: const DeveloperView(),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _transitionDuration = Duration(milliseconds: 300);
|
||||
@@ -119,10 +121,22 @@ class _AnimatedVisibilityState extends State<AnimatedVisibility>
|
||||
Widget build(BuildContext context) {
|
||||
final motion = widget._motion;
|
||||
final content = _includeChild
|
||||
? SizeTransition(
|
||||
sizeFactor: _animation,
|
||||
axis: motion.axis,
|
||||
alignment: motion.alignment,
|
||||
? AnimatedBuilder(
|
||||
animation: _animation,
|
||||
// Settled, it keeps what paints past its bounds: shadows, a lens.
|
||||
builder: (_, child) => ClipRect(
|
||||
clipBehavior: _controller.isCompleted ? Clip.none : Clip.hardEdge,
|
||||
child: Align(
|
||||
alignment: motion.alignment,
|
||||
widthFactor: motion.axis == Axis.horizontal
|
||||
? math.max(_animation.value, 0)
|
||||
: null,
|
||||
heightFactor: motion.axis == Axis.vertical
|
||||
? math.max(_animation.value, 0)
|
||||
: null,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: _animation,
|
||||
child: SlideTransition(
|
||||
@@ -138,10 +152,7 @@ class _AnimatedVisibilityState extends State<AnimatedVisibility>
|
||||
excluding: !widget.visible,
|
||||
child: ExcludeFocus(
|
||||
excluding: !widget.visible,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible,
|
||||
child: ClipRect(child: content),
|
||||
),
|
||||
child: IgnorePointer(ignoring: !widget.visible, child: content),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+195
-28
@@ -1,12 +1,14 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'builder.dart';
|
||||
import 'card.dart';
|
||||
import 'navigation_dock.dart';
|
||||
|
||||
class CommonFloatingActionButton extends StatelessWidget {
|
||||
class CommonFloatingActionButton extends StatefulWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final Icon icon;
|
||||
final GlyphIcon icon;
|
||||
final String label;
|
||||
|
||||
const CommonFloatingActionButton({
|
||||
@@ -16,37 +18,91 @@ class CommonFloatingActionButton extends StatelessWidget {
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CommonFloatingActionButton> createState() =>
|
||||
_CommonFloatingActionButtonState();
|
||||
}
|
||||
|
||||
class _CommonFloatingActionButtonState
|
||||
extends State<CommonFloatingActionButton> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
final isFocused = _focusNode.hasFocus;
|
||||
if (isFocused == _isFocused) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isFocused = isFocused;
|
||||
});
|
||||
}
|
||||
|
||||
OutlinedBorder? _buildFocusedShape(ThemeData theme) {
|
||||
if (!_isFocused) {
|
||||
return null;
|
||||
}
|
||||
final base = switch (theme.floatingActionButtonTheme.shape) {
|
||||
final OutlinedBorder shape => shape,
|
||||
_ => AppShape.md,
|
||||
};
|
||||
return base.copyWith(
|
||||
side: BorderSide(color: theme.colorScheme.primary, width: 3),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
floatingActionButtonTheme: Theme.of(context).floatingActionButtonTheme
|
||||
.copyWith(
|
||||
extendedIconLabelSpacing: 0,
|
||||
extendedPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
data: theme.copyWith(
|
||||
floatingActionButtonTheme: theme.floatingActionButtonTheme.copyWith(
|
||||
extendedIconLabelSpacing: 0,
|
||||
extendedPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
child: FloatingActionButtonExtendedBuilder(
|
||||
builder: (isExtended) {
|
||||
return FloatingActionButton.extended(
|
||||
heroTag: null,
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
isExtended: true,
|
||||
label: AnimatedSize(
|
||||
alignment: Alignment.centerLeft,
|
||||
duration: midDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
child: AnimatedOpacity(
|
||||
duration: midDuration,
|
||||
opacity: isExtended ? 1.0 : 0.4,
|
||||
curve: Curves.linear,
|
||||
child: isExtended
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(label, softWrap: false),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
return ElasticButton(
|
||||
enabled: widget.onPressed != null,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: null,
|
||||
focusNode: _focusNode,
|
||||
shape: _buildFocusedShape(theme),
|
||||
icon: widget.icon,
|
||||
onPressed: widget.onPressed,
|
||||
isExtended: true,
|
||||
label: Semantics(
|
||||
label: isExtended ? null : widget.label,
|
||||
child: AnimatedSize(
|
||||
alignment: Alignment.centerLeft,
|
||||
duration: midDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
child: AnimatedOpacity(
|
||||
duration: midDuration,
|
||||
opacity: isExtended ? 1.0 : 0.4,
|
||||
curve: Curves.linear,
|
||||
child: isExtended
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(widget.label, softWrap: false),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -84,7 +140,118 @@ class MoreActionButton extends StatelessWidget {
|
||||
vertical: 16,
|
||||
),
|
||||
title: Text(label, style: context.textTheme.bodyLarge),
|
||||
trailing: trailing ?? const Icon(Icons.arrow_forward_ios, size: 18),
|
||||
trailing:
|
||||
trailing ?? const GlyphIcon(AppGlyphs.chevronForward, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum TonalButtonSize {
|
||||
bar(button: 40, icon: 22),
|
||||
compact(button: 32, icon: 20);
|
||||
|
||||
const TonalButtonSize({required this.button, required this.icon});
|
||||
|
||||
final double button;
|
||||
final double icon;
|
||||
}
|
||||
|
||||
/// Buttons sharing one filled pill, which answers a press on any of them as a
|
||||
/// single button does.
|
||||
class TonalButtonGroup extends StatelessWidget {
|
||||
const TonalButtonGroup({
|
||||
super.key,
|
||||
this.size = TonalButtonSize.bar,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final TonalButtonSize size;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElasticPress(
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.secondaryContainer,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: TonalButtonTheme(
|
||||
grouped: true,
|
||||
size: size,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: children),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills every button below and, as iOS does, leaves a press to the swell of
|
||||
/// [ElasticPress]; inside a [TonalButtonGroup] the group is filled.
|
||||
class TonalButtonTheme extends StatelessWidget {
|
||||
const TonalButtonTheme({
|
||||
super.key,
|
||||
this.grouped = false,
|
||||
this.size = TonalButtonSize.bar,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool grouped;
|
||||
final TonalButtonSize size;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final fill = grouped ? Colors.transparent : colorScheme.secondaryContainer;
|
||||
final foreground = colorScheme.onSecondaryContainer;
|
||||
final disabledForeground = foreground.withValues(alpha: 0.38);
|
||||
ButtonStyle feedback(Color? tint) => ButtonStyle(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStateProperty<Color?>.fromMap({
|
||||
if (tint != null) WidgetState.focused: tint.withValues(alpha: 0.1),
|
||||
WidgetState.pressed: Colors.transparent,
|
||||
if (tint != null) WidgetState.hovered: tint.withValues(alpha: 0.08),
|
||||
}),
|
||||
);
|
||||
return IconButtonTheme(
|
||||
data: IconButtonThemeData(
|
||||
style: feedback(foreground).merge(
|
||||
IconButton.styleFrom(
|
||||
backgroundColor: fill,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: fill,
|
||||
disabledForegroundColor: disabledForeground,
|
||||
fixedSize: Size.square(size.button),
|
||||
minimumSize: Size.square(size.button),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: size.icon,
|
||||
visualDensity: VisualDensity.standard,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: FilledButtonTheme(
|
||||
data: FilledButtonThemeData(
|
||||
style: feedback(grouped ? foreground : null).merge(
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor: grouped ? fill : null,
|
||||
foregroundColor: grouped ? foreground : null,
|
||||
disabledBackgroundColor: grouped ? fill : null,
|
||||
disabledForegroundColor: grouped ? disabledForeground : null,
|
||||
minimumSize: Size.square(size.button),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
shape: AppShape.full,
|
||||
visualDensity: VisualDensity.standard,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: const IconThemeData(fill: 1),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const double _flingVelocity = 1.0;
|
||||
const Duration _settleDuration = Duration(milliseconds: 350);
|
||||
|
||||
final Animatable<Offset> _slideTween = Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
);
|
||||
|
||||
/// Lets a press anywhere on the route drag it toward the reading end to pop.
|
||||
/// A horizontal scrollable, slider or text field under the pointer is deeper
|
||||
/// in the hit test, so it wins the arena and keeps the drag.
|
||||
mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
bool _dragBackActive = false;
|
||||
|
||||
/// While true the route's own transition should follow the finger linearly,
|
||||
/// through the release until the route settles.
|
||||
bool get isDragBackActive => _dragBackActive;
|
||||
|
||||
@protected
|
||||
void didStartDragBack() {}
|
||||
|
||||
Widget dragBackDetector(Widget child) {
|
||||
return _DragBackDetector(route: this, child: child);
|
||||
}
|
||||
|
||||
Widget dragBackSlide(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return SlideTransition(
|
||||
position: animation.drive(_slideTween),
|
||||
textDirection: Directionality.of(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canDragBack => isCurrent && popGestureEnabled;
|
||||
|
||||
void _startDragBack() {
|
||||
_dragBackActive = true;
|
||||
didStartDragBack();
|
||||
navigator!.didStartUserGesture();
|
||||
}
|
||||
|
||||
void _updateDragBack(double delta) {
|
||||
controller!.value -= delta;
|
||||
}
|
||||
|
||||
void _endDragBack(double velocity) {
|
||||
final controller = this.controller!;
|
||||
final navigator = this.navigator!;
|
||||
final bool settleForward;
|
||||
if (!isCurrent) {
|
||||
settleForward = isActive;
|
||||
} else if (velocity.abs() >= _flingVelocity) {
|
||||
settleForward = velocity <= 0;
|
||||
} else {
|
||||
settleForward = controller.value > 0.5;
|
||||
}
|
||||
if (settleForward) {
|
||||
controller.animateTo(
|
||||
1.0,
|
||||
duration: _settleDuration,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
);
|
||||
} else {
|
||||
if (isCurrent) {
|
||||
navigator.pop();
|
||||
}
|
||||
if (controller.isAnimating) {
|
||||
controller.animateBack(
|
||||
0.0,
|
||||
duration: _settleDuration,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!controller.isAnimating) {
|
||||
_finishDragBack(navigator);
|
||||
return;
|
||||
}
|
||||
void handleStatus(AnimationStatus status) {
|
||||
if (status.isAnimating) {
|
||||
return;
|
||||
}
|
||||
controller.removeStatusListener(handleStatus);
|
||||
_finishDragBack(navigator);
|
||||
}
|
||||
|
||||
controller.addStatusListener(handleStatus);
|
||||
}
|
||||
|
||||
void _finishDragBack(NavigatorState navigator) {
|
||||
if (!_dragBackActive) {
|
||||
return;
|
||||
}
|
||||
_dragBackActive = false;
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
|
||||
void _abortDragBack() {
|
||||
final navigator = this.navigator;
|
||||
if (!_dragBackActive || navigator == null) {
|
||||
return;
|
||||
}
|
||||
_dragBackActive = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (navigator.mounted) {
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _DragBackDetector extends StatefulWidget {
|
||||
const _DragBackDetector({required this.route, required this.child});
|
||||
|
||||
final DragBackRouteMixin<dynamic> route;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_DragBackDetector> createState() => _DragBackDetectorState();
|
||||
}
|
||||
|
||||
class _DragBackDetectorState extends State<_DragBackDetector> {
|
||||
late final _DragBackGestureRecognizer _recognizer;
|
||||
bool _dragging = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = _DragBackGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recognizer.dispose();
|
||||
if (_dragging) {
|
||||
widget.route._abortDragBack();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _toLogical(double value) {
|
||||
return switch (Directionality.of(context)) {
|
||||
TextDirection.rtl => -value,
|
||||
TextDirection.ltr => value,
|
||||
};
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.route._canDragBack) {
|
||||
_recognizer.addPointer(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
_dragging = true;
|
||||
widget.route._startDragBack();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
if (!_dragging) {
|
||||
return;
|
||||
}
|
||||
widget.route._updateDragBack(
|
||||
_toLogical(details.primaryDelta! / context.size!.width),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
if (!_dragging) {
|
||||
return;
|
||||
}
|
||||
_dragging = false;
|
||||
widget.route._endDragBack(
|
||||
_toLogical(details.velocity.pixelsPerSecond.dx / context.size!.width),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
if (!_dragging) {
|
||||
return;
|
||||
}
|
||||
_dragging = false;
|
||||
widget.route._endDragBack(0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop text fields select with a pan, which accepts past the pan slop.
|
||||
class _DragBackGestureRecognizer extends HorizontalDragGestureRecognizer {
|
||||
_DragBackGestureRecognizer({super.debugOwner});
|
||||
|
||||
@override
|
||||
bool hasSufficientGlobalDistanceToAccept(
|
||||
PointerDeviceKind pointerDeviceKind,
|
||||
double? deviceTouchSlop,
|
||||
) {
|
||||
return switch (defaultTargetPlatform) {
|
||||
TargetPlatform.linux || TargetPlatform.macOS || TargetPlatform.windows =>
|
||||
globalDistanceMoved.abs() >
|
||||
computePanSlop(pointerDeviceKind, gestureSettings),
|
||||
_ => super.hasSufficientGlobalDistanceToAccept(
|
||||
pointerDeviceKind,
|
||||
deviceTouchSlop,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import 'package:fl_clash/common/system.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'inherited.dart';
|
||||
|
||||
class FloatLayout extends StatelessWidget {
|
||||
final Widget floatingWidget;
|
||||
final Widget child;
|
||||
final bool? isTV;
|
||||
|
||||
const FloatLayout({
|
||||
super.key,
|
||||
required this.floatingWidget,
|
||||
this.isTV,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isTV ?? system.isTV) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
floatingWidget,
|
||||
Expanded(child: Center(child: child)),
|
||||
],
|
||||
);
|
||||
}
|
||||
final bottomInset = BottomInsetScope.of(context);
|
||||
return Stack(
|
||||
fit: StackFit.loose,
|
||||
children: [
|
||||
Center(
|
||||
child: BottomInsetScope(
|
||||
inset: bottomInset + BottomInsetScope.floatingActionButtonInset,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned(bottom: bottomInset, right: 0, child: floatingWidget),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FloatWrapper extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const FloatWrapper({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(kFloatingActionButtonMargin),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+228
-7
@@ -1,3 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class PageFocusScope extends StatefulWidget {
|
||||
@@ -29,6 +32,9 @@ class _PageFocusScopeState extends State<PageFocusScope> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a page has no target in a direction, the search continues among the
|
||||
/// nodes outside the page (sidebar, navigation bar) from the focused control's
|
||||
/// own rect, so left lands on the sidebar item beside it instead of wrapping.
|
||||
class PageTraversalPolicy extends ReadingOrderTraversalPolicy {
|
||||
@override
|
||||
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
|
||||
@@ -36,15 +42,230 @@ class PageTraversalPolicy extends ReadingOrderTraversalPolicy {
|
||||
if (super.inDirection(currentNode, direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final parent = scope?.enclosingScope;
|
||||
if (parent == null || parent == FocusManager.instance.rootScope) {
|
||||
if (scope == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (direction) {
|
||||
TraversalDirection.down || TraversalDirection.right => parent.nextFocus(),
|
||||
TraversalDirection.up ||
|
||||
TraversalDirection.left => parent.previousFocus(),
|
||||
final found = _findOutside(scope, currentNode.rect, direction);
|
||||
if (found == null) {
|
||||
return false;
|
||||
}
|
||||
requestFocusCallback(
|
||||
found,
|
||||
alignmentPolicy: switch (direction) {
|
||||
TraversalDirection.up || TraversalDirection.left =>
|
||||
ScrollPositionAlignmentPolicy.keepVisibleAtStart,
|
||||
TraversalDirection.down || TraversalDirection.right =>
|
||||
ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
FocusNode? _findOutside(
|
||||
FocusScopeNode scope,
|
||||
Rect from,
|
||||
TraversalDirection direction,
|
||||
) {
|
||||
final root = FocusManager.instance.rootScope;
|
||||
for (
|
||||
var parent = scope.enclosingScope;
|
||||
parent != null && parent != root;
|
||||
parent = parent.enclosingScope
|
||||
) {
|
||||
final level = parent;
|
||||
final candidates = level.traversalDescendants.where(
|
||||
(node) => node != scope && _isOutside(node, scope, level),
|
||||
);
|
||||
final found = _nearestInDirection(from, candidates, direction);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A covered route's scope is marked skipped; its nodes stay in the tree.
|
||||
static bool _isOutside(
|
||||
FocusNode node,
|
||||
FocusScopeNode scope,
|
||||
FocusScopeNode level,
|
||||
) {
|
||||
for (final ancestor in node.ancestors) {
|
||||
if (ancestor == level) {
|
||||
return true;
|
||||
}
|
||||
if (ancestor == scope ||
|
||||
ancestor is FocusScopeNode && ancestor.skipTraversal) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static FocusNode? _nearestInDirection(
|
||||
Rect from,
|
||||
Iterable<FocusNode> nodes,
|
||||
TraversalDirection direction,
|
||||
) {
|
||||
final vertical =
|
||||
direction == TraversalDirection.up ||
|
||||
direction == TraversalDirection.down;
|
||||
bool ahead(Rect rect) => switch (direction) {
|
||||
TraversalDirection.up => rect.center.dy <= from.top,
|
||||
TraversalDirection.down => rect.center.dy >= from.bottom,
|
||||
TraversalDirection.left => rect.center.dx <= from.left,
|
||||
TraversalDirection.right => rect.center.dx >= from.right,
|
||||
};
|
||||
bool inBand(Rect rect) => vertical
|
||||
? rect.right > from.left && rect.left < from.right
|
||||
: rect.bottom > from.top && rect.top < from.bottom;
|
||||
double along(Rect rect) => math.max(switch (direction) {
|
||||
TraversalDirection.up => from.top - rect.bottom,
|
||||
TraversalDirection.down => rect.top - from.bottom,
|
||||
TraversalDirection.left => from.left - rect.right,
|
||||
TraversalDirection.right => rect.left - from.right,
|
||||
}, 0);
|
||||
double across(Rect rect) => vertical
|
||||
? (rect.center.dx - from.center.dx).abs()
|
||||
: (rect.center.dy - from.center.dy).abs();
|
||||
|
||||
final aheadNodes = nodes.where((node) => ahead(node.rect)).toList();
|
||||
final banded = aheadNodes.where((node) => inBand(node.rect)).toList();
|
||||
if (banded.isNotEmpty) {
|
||||
banded.sort((a, b) {
|
||||
final primary = along(a.rect).compareTo(along(b.rect));
|
||||
return primary != 0
|
||||
? primary
|
||||
: across(a.rect).compareTo(across(b.rect));
|
||||
});
|
||||
return banded.first;
|
||||
}
|
||||
if (vertical || aheadNodes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
aheadNodes.sort((a, b) {
|
||||
final primary = across(a.rect).compareTo(across(b.rect));
|
||||
return primary != 0 ? primary : along(a.rect).compareTo(along(b.rect));
|
||||
});
|
||||
return aheadNodes.first;
|
||||
}
|
||||
}
|
||||
|
||||
/// D-pad behaviour a remote-driven screen expects: a route that opens with
|
||||
/// focus on its scope moves it to the first control, and arrows at the edge of
|
||||
/// a text field leave the field instead of stalling the caret.
|
||||
class RemoteFocusAdapter extends StatefulWidget {
|
||||
final bool enabled;
|
||||
final Widget child;
|
||||
|
||||
const RemoteFocusAdapter({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RemoteFocusAdapter> createState() => _RemoteFocusAdapterState();
|
||||
}
|
||||
|
||||
class _RemoteFocusAdapterState extends State<RemoteFocusAdapter> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.enabled) {
|
||||
FocusManager.instance.addListener(_handleFocusChange);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RemoteFocusAdapter oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.enabled == widget.enabled) {
|
||||
return;
|
||||
}
|
||||
if (widget.enabled) {
|
||||
FocusManager.instance.addListener(_handleFocusChange);
|
||||
} else {
|
||||
FocusManager.instance.removeListener(_handleFocusChange);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.enabled) {
|
||||
FocusManager.instance.removeListener(_handleFocusChange);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
if (focus is FocusScopeNode && focus.focusedChild == null) {
|
||||
focus.nextFocus();
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!widget.enabled || event is KeyUpEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final direction = switch (event.logicalKey) {
|
||||
LogicalKeyboardKey.arrowUp => TraversalDirection.up,
|
||||
LogicalKeyboardKey.arrowDown => TraversalDirection.down,
|
||||
LogicalKeyboardKey.arrowLeft => TraversalDirection.left,
|
||||
LogicalKeyboardKey.arrowRight => TraversalDirection.right,
|
||||
_ => null,
|
||||
};
|
||||
if (direction == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
final editable = focus?.context
|
||||
?.findAncestorStateOfType<EditableTextState>();
|
||||
if (focus == null ||
|
||||
editable == null ||
|
||||
!_caretAtEdge(editable, direction)) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
return focus.focusInDirection(direction)
|
||||
? KeyEventResult.handled
|
||||
: KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
static bool _caretAtEdge(
|
||||
EditableTextState editable,
|
||||
TraversalDirection direction,
|
||||
) {
|
||||
final value = editable.textEditingValue;
|
||||
final selection = value.selection;
|
||||
if (!selection.isValid || !selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
final length = value.text.length;
|
||||
switch (direction) {
|
||||
case TraversalDirection.left:
|
||||
return selection.baseOffset == 0;
|
||||
case TraversalDirection.right:
|
||||
return selection.baseOffset == length;
|
||||
case TraversalDirection.up:
|
||||
case TraversalDirection.down:
|
||||
final render = editable.renderEditable;
|
||||
final caret = render.getLocalRectForCaret(selection.extent);
|
||||
final edge = render.getLocalRectForCaret(
|
||||
TextPosition(offset: direction == TraversalDirection.up ? 0 : length),
|
||||
);
|
||||
return (caret.top - edge.top).abs() < 1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
includeSemantics: false,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -32,6 +33,11 @@ class BottomInsetScope extends InheritedWidget {
|
||||
static const double floatingActionButtonInset =
|
||||
kFloatingActionButtonMargin + _floatingActionButtonHeight;
|
||||
|
||||
static const double dockedSearchHeight = 48;
|
||||
static const double dockedSearchMargin = kFloatingActionButtonMargin;
|
||||
static const double dockedSearchInset =
|
||||
dockedSearchMargin + dockedSearchHeight;
|
||||
|
||||
final double inset;
|
||||
|
||||
const BottomInsetScope({
|
||||
@@ -53,6 +59,45 @@ class BottomInsetScope extends InheritedWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The room content still has to leave at its top, where the scaffold has
|
||||
/// already placed something of its own there.
|
||||
class TopInsetScope extends InheritedWidget {
|
||||
final double inset;
|
||||
|
||||
const TopInsetScope({super.key, required this.inset, required super.child});
|
||||
|
||||
static double? of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<TopInsetScope>()?.inset;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(TopInsetScope oldWidget) {
|
||||
return inset != oldWidget.inset;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a page's app bar reaches over the body floating under it.
|
||||
class FloatingBarScope extends InheritedWidget {
|
||||
final double inset;
|
||||
|
||||
const FloatingBarScope({
|
||||
super.key,
|
||||
required this.inset,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static double? of(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<FloatingBarScope>()
|
||||
?.inset;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(FloatingBarScope oldWidget) {
|
||||
return inset != oldWidget.inset;
|
||||
}
|
||||
}
|
||||
|
||||
class CommonScaffoldBackActionProvider extends InheritedWidget {
|
||||
final VoidCallback? backAction;
|
||||
|
||||
@@ -189,3 +234,98 @@ class ProfileIdProvider extends InheritedWidget {
|
||||
bool updateShouldNotify(ProfileIdProvider oldWidget) =>
|
||||
profileId != oldWidget.profileId;
|
||||
}
|
||||
|
||||
/// How far a sheet's content hangs below the screen at its current detent.
|
||||
class SheetOverhangScope extends InheritedWidget {
|
||||
final ValueListenable<double> overhang;
|
||||
final bool fitsContent;
|
||||
|
||||
const SheetOverhangScope({
|
||||
super.key,
|
||||
required this.overhang,
|
||||
this.fitsContent = false,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ValueListenable<double>? of(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<SheetOverhangScope>()
|
||||
?.overhang;
|
||||
}
|
||||
|
||||
static bool fillsDetentOf(BuildContext context) {
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<SheetOverhangScope>();
|
||||
return scope != null && !scope.fitsContent;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetOverhangScope oldWidget) =>
|
||||
overhang != oldWidget.overhang || fitsContent != oldWidget.fitsContent;
|
||||
}
|
||||
|
||||
/// Resolves once the content has closed the sheet or decided to keep it.
|
||||
typedef SheetDismissHandler = Future<void> Function();
|
||||
|
||||
/// Lets a sheet's content take over what a drag away or a tap outside does.
|
||||
class SheetDismissScope extends InheritedWidget {
|
||||
final ValueNotifier<SheetDismissHandler?> handler;
|
||||
|
||||
const SheetDismissScope({
|
||||
super.key,
|
||||
required this.handler,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ValueNotifier<SheetDismissHandler?>? of(BuildContext context) {
|
||||
return context.getInheritedWidgetOfExactType<SheetDismissScope>()?.handler;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetDismissScope oldWidget) =>
|
||||
handler != oldWidget.handler;
|
||||
}
|
||||
|
||||
/// Makes a scroll controller that hands drags to the sheet, one per route
|
||||
/// inside it: a controller shared by pages kept alive together fails the
|
||||
/// scrollbar's single position check.
|
||||
class SheetScrollScope extends InheritedWidget {
|
||||
final ScrollController Function() createController;
|
||||
|
||||
const SheetScrollScope({
|
||||
super.key,
|
||||
required this.createController,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ScrollController Function()? of(BuildContext context) {
|
||||
return context
|
||||
.getInheritedWidgetOfExactType<SheetScrollScope>()
|
||||
?.createController;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetScrollScope oldWidget) =>
|
||||
createController != oldWidget.createController;
|
||||
}
|
||||
|
||||
/// Whether a sheet is animating to a detent, which resizes its content.
|
||||
class SheetSettlingScope extends InheritedWidget {
|
||||
final ValueListenable<bool> settling;
|
||||
|
||||
const SheetSettlingScope({
|
||||
super.key,
|
||||
required this.settling,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ValueListenable<bool>? of(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<SheetSettlingScope>()
|
||||
?.settling;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetSettlingScope oldWidget) =>
|
||||
settling != oldWidget.settling;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'inherited.dart';
|
||||
|
||||
typedef _BottomInset = ({double viewInset, double padding});
|
||||
|
||||
/// Holds the bottom inset of a page under another route or on an inactive
|
||||
/// tab, which is still laid out, offstage or not, and would otherwise resize
|
||||
/// with every frame of a keyboard it cannot use. It catches up once the
|
||||
/// keyboard comes down to it or the focus moves inside.
|
||||
class KeyboardInsetHold extends StatefulWidget {
|
||||
const KeyboardInsetHold({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<KeyboardInsetHold> createState() => _KeyboardInsetHoldState();
|
||||
}
|
||||
|
||||
class _KeyboardInsetHoldState extends State<KeyboardInsetHold> {
|
||||
_BottomInset? _shown;
|
||||
_BottomInset? _held;
|
||||
FocusNode? _enclosingFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
FocusManager.instance.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
FocusManager.instance.removeListener(_handleFocusChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (_held != null && _hasFocusInside) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasFocusInside {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
final enclosing = _enclosingFocus;
|
||||
return focus != null &&
|
||||
enclosing != null &&
|
||||
focus is! FocusScopeNode &&
|
||||
focus.ancestors.contains(enclosing);
|
||||
}
|
||||
|
||||
_BottomInset? _nextHeld(_BottomInset live) {
|
||||
if (_hasFocusInside) {
|
||||
return null;
|
||||
}
|
||||
final isShown =
|
||||
(ModalRoute.isCurrentOf(context) ?? true) &&
|
||||
PageActivityScope.isActiveOf(context);
|
||||
final held = _held ?? (isShown ? null : _shown);
|
||||
if (held == null || live.viewInset <= held.viewInset) {
|
||||
return isShown ? null : live;
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_enclosingFocus = Focus.maybeOf(context, scopeOk: true);
|
||||
final data = MediaQuery.of(context);
|
||||
final live = (
|
||||
viewInset: data.viewInsets.bottom,
|
||||
padding: data.padding.bottom,
|
||||
);
|
||||
final held = _held = _nextHeld(live);
|
||||
_shown = held ?? live;
|
||||
return MediaQuery(
|
||||
data: held == null
|
||||
? data
|
||||
: data.copyWith(
|
||||
viewInsets: data.viewInsets.copyWith(bottom: held.viewInset),
|
||||
padding: data.padding.copyWith(bottom: held.padding),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
|
||||
typedef CloseContainerActionCallback<S> = void Function({S? returnValue});
|
||||
typedef OpenContainerBuilder<S> =
|
||||
@@ -18,25 +20,35 @@ typedef ClosedCallback<S> = void Function(S data);
|
||||
class OpenContainer<T extends Object?> extends StatefulWidget {
|
||||
const OpenContainer({
|
||||
super.key,
|
||||
this.closedColor,
|
||||
this.openColor,
|
||||
this.middleColor,
|
||||
this.closedShape,
|
||||
this.openShape,
|
||||
this.onClosed,
|
||||
required this.closedBuilder,
|
||||
required this.openBuilder,
|
||||
this.tappable = true,
|
||||
this.transitionDuration = const Duration(milliseconds: 300),
|
||||
this.transitionType = ContainerTransitionType.fade,
|
||||
this.curve = Curves.fastOutSlowIn,
|
||||
this.useRootNavigator = false,
|
||||
this.routeSettings,
|
||||
this.clipBehavior = Clip.antiAlias,
|
||||
});
|
||||
|
||||
final Color? closedColor;
|
||||
final Color? openColor;
|
||||
final Color? middleColor;
|
||||
final ShapeBorder? closedShape;
|
||||
final ShapeBorder? openShape;
|
||||
final ClosedCallback<T?>? onClosed;
|
||||
final CloseContainerBuilder closedBuilder;
|
||||
final OpenContainerBuilder<T> openBuilder;
|
||||
final bool tappable;
|
||||
final Duration transitionDuration;
|
||||
final ContainerTransitionType transitionType;
|
||||
final Curve curve;
|
||||
final bool useRootNavigator;
|
||||
final RouteSettings? routeSettings;
|
||||
final Clip clipBehavior;
|
||||
@@ -58,13 +70,18 @@ class _OpenContainerState<T> extends State<OpenContainer<T?>> {
|
||||
rootNavigator: widget.useRootNavigator,
|
||||
).push(
|
||||
_OpenContainerRoute<T>(
|
||||
closedColor: widget.closedColor,
|
||||
openColor: widget.openColor,
|
||||
middleColor: middleColor,
|
||||
closedShape: widget.closedShape,
|
||||
openShape: widget.openShape,
|
||||
closedBuilder: widget.closedBuilder,
|
||||
openBuilder: widget.openBuilder,
|
||||
hideableKey: _hideableKey,
|
||||
closedBuilderKey: _closedBuilderKey,
|
||||
transitionDuration: widget.transitionDuration,
|
||||
transitionType: widget.transitionType,
|
||||
curve: widget.curve,
|
||||
useRootNavigator: widget.useRootNavigator,
|
||||
routeSettings: widget.routeSettings,
|
||||
),
|
||||
@@ -83,6 +100,7 @@ class _OpenContainerState<T> extends State<OpenContainer<T?>> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
shape: widget.closedShape,
|
||||
child: Builder(
|
||||
key: _closedBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
@@ -146,19 +164,25 @@ class _HideableState extends State<_Hideable> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
_OpenContainerRoute({
|
||||
required this.closedColor,
|
||||
required this.openColor,
|
||||
required this.middleColor,
|
||||
required ShapeBorder? closedShape,
|
||||
required this.openShape,
|
||||
required this.closedBuilder,
|
||||
required this.openBuilder,
|
||||
required this.hideableKey,
|
||||
required this.closedBuilderKey,
|
||||
required this.transitionDuration,
|
||||
required this.transitionType,
|
||||
required this.curve,
|
||||
required this.useRootNavigator,
|
||||
required RouteSettings? routeSettings,
|
||||
}) : _closedOpacityTween = _getClosedOpacityTween(transitionType),
|
||||
_openOpacityTween = _getOpenOpacityTween(transitionType),
|
||||
_shapeTween = ShapeBorderTween(begin: closedShape, end: openShape),
|
||||
super(settings: routeSettings);
|
||||
|
||||
static _FlippableTweenSequence<Color?> _getColorTween({
|
||||
@@ -255,7 +279,10 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
}
|
||||
}
|
||||
|
||||
final Color? closedColor;
|
||||
final Color? openColor;
|
||||
final Color middleColor;
|
||||
final ShapeBorder? openShape;
|
||||
final CloseContainerBuilder closedBuilder;
|
||||
final OpenContainerBuilder<T> openBuilder;
|
||||
final GlobalKey<_HideableState> hideableKey;
|
||||
@@ -264,11 +291,13 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
@override
|
||||
final Duration transitionDuration;
|
||||
final ContainerTransitionType transitionType;
|
||||
final Curve curve;
|
||||
|
||||
final bool useRootNavigator;
|
||||
|
||||
final _FlippableTweenSequence<double> _closedOpacityTween;
|
||||
final _FlippableTweenSequence<double> _openOpacityTween;
|
||||
final ShapeBorderTween _shapeTween;
|
||||
final Animatable<Color?> _scrimTween = ColorTween(
|
||||
begin: Colors.transparent,
|
||||
end: Colors.transparent,
|
||||
@@ -305,6 +334,9 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
|
||||
@override
|
||||
bool didPop(T? result) {
|
||||
if (isDragBackActive) {
|
||||
return super.didPop(result);
|
||||
}
|
||||
_takeMeasurements(
|
||||
navigatorContext: subtreeContext!,
|
||||
delayForSourceRoute: true,
|
||||
@@ -418,10 +450,11 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
_colorTween = _getColorTween(
|
||||
transitionType: transitionType,
|
||||
closedColor: Theme.of(context).colorScheme.surface,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
closedColor: closedColor ?? surface,
|
||||
openColor: openColor ?? surface,
|
||||
middleColor: middleColor,
|
||||
);
|
||||
return Align(
|
||||
@@ -429,13 +462,16 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
if (animation.isCompleted) {
|
||||
if (animation.isCompleted || isDragBackActive) {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
shape: openShape,
|
||||
child: Builder(
|
||||
key: _openBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
return openBuilder(context, closeContainer);
|
||||
return KeyboardInsetHold(
|
||||
child: openBuilder(context, closeContainer),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -444,10 +480,8 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
|
||||
final Animation<double> curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
reverseCurve: _transitionWasInterrupted
|
||||
? null
|
||||
: Curves.fastOutSlowIn.flipped,
|
||||
curve: curve,
|
||||
reverseCurve: _transitionWasInterrupted ? null : curve.flipped,
|
||||
);
|
||||
TweenSequence<Color?>? colorTween;
|
||||
TweenSequence<double>? closedOpacityTween, openOpacityTween;
|
||||
@@ -492,6 +526,7 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
animationDuration: Duration.zero,
|
||||
color: colorTween!.evaluate(animation),
|
||||
shape: _shapeTween.evaluate(curvedAnimation),
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
@@ -531,7 +566,12 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
child: Builder(
|
||||
key: _openBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
return openBuilder(context, closeContainer);
|
||||
return KeyboardInsetHold(
|
||||
child: openBuilder(
|
||||
context,
|
||||
closeContainer,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -550,6 +590,21 @@ class _OpenContainerRoute<T> extends ModalRoute<T> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStartDragBack() => _toggleHideable(hide: false);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return dragBackDetector(
|
||||
isDragBackActive ? dragBackSlide(context, animation, child) : child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get maintainState => true;
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/pop_scope.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet_navigator.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:navigator_resizable/navigator_resizable.dart';
|
||||
|
||||
@@ -10,7 +15,8 @@ Color _sheetColorOf(BuildContext context) {
|
||||
: ColorScheme.of(context).surface;
|
||||
}
|
||||
|
||||
class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
class PagedSheetRoute<T> extends PageRoute<T>
|
||||
with ObservableRouteMixin<T>, DragBackRouteMixin<T> {
|
||||
PagedSheetRoute({
|
||||
super.settings,
|
||||
super.fullscreenDialog,
|
||||
@@ -25,6 +31,8 @@ class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
ScrollController? _sheetScroll;
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
|
||||
@@ -32,6 +40,12 @@ class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
final Color? backgroundColor;
|
||||
final RouteTransitionsBuilder? transitionsBuilder;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sheetScroll?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@@ -60,10 +74,24 @@ class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final createSheetScroll = SheetScrollScope.of(context);
|
||||
if (createSheetScroll != null) {
|
||||
_sheetScroll ??= createSheetScroll();
|
||||
}
|
||||
final sheetScroll = _sheetScroll;
|
||||
final page = builder(context);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: ResizableNavigatorRouteContentBoundary(child: builder(context)),
|
||||
child: ResizableNavigatorRouteContentBoundary(
|
||||
child: sheetScroll == null
|
||||
? page
|
||||
: PrimaryScrollController(
|
||||
controller: sheetScroll,
|
||||
automaticallyInheritForPlatforms: TargetPlatform.values.toSet(),
|
||||
child: page,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,12 +102,18 @@ class PagedSheetRoute<T> extends PageRoute<T> with ObservableRouteMixin<T> {
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
if (transitionsBuilder case final builder?) {
|
||||
return builder(context, animation, secondaryAnimation, child);
|
||||
}
|
||||
return FadeForwardsPageTransitionsBuilder(
|
||||
backgroundColor: backgroundColor ?? _sheetColorOf(context),
|
||||
).buildTransitions(this, context, animation, secondaryAnimation, child);
|
||||
final color = backgroundColor ?? _sheetColorOf(context);
|
||||
return dragBackDetector(switch (transitionsBuilder) {
|
||||
_ when isDragBackActive => dragBackSlide(
|
||||
context,
|
||||
animation,
|
||||
ColoredBox(color: color, child: child),
|
||||
),
|
||||
final builder? => builder(context, animation, secondaryAnimation, child),
|
||||
null => FadeForwardsPageTransitionsBuilder(
|
||||
backgroundColor: color,
|
||||
).buildTransitions(this, context, animation, secondaryAnimation, child),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,3 +148,127 @@ class PagedSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedPagedSheetProps = SheetProps(
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
maxWidth: double.maxFinite,
|
||||
);
|
||||
|
||||
/// Opened with [nestedPagedSheetProps]. [onDismiss] is told whether a page is
|
||||
/// pushed over the root; with no callbacks, back on the root and a tap outside
|
||||
/// close the sheet.
|
||||
class NestedPagedSheet extends StatefulWidget {
|
||||
const NestedPagedSheet({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.onExit,
|
||||
this.onDismiss,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
final VoidCallback? onExit;
|
||||
final FutureOr<void> Function(bool hasPushedPages)? onDismiss;
|
||||
|
||||
@override
|
||||
State<NestedPagedSheet> createState() => _NestedPagedSheetState();
|
||||
}
|
||||
|
||||
class _NestedPagedSheetState extends State<NestedPagedSheet> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey();
|
||||
ValueNotifier<SheetDismissHandler?>? _dismissHandler;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final handler = SheetDismissScope.of(context);
|
||||
if (identical(handler, _dismissHandler)) {
|
||||
return;
|
||||
}
|
||||
_releaseDismiss();
|
||||
_dismissHandler = handler?..value = _handleDismiss;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_releaseDismiss();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _releaseDismiss() {
|
||||
final handler = _dismissHandler;
|
||||
if (handler != null && handler.value == _handleDismiss) {
|
||||
handler.value = null;
|
||||
}
|
||||
_dismissHandler = null;
|
||||
}
|
||||
|
||||
bool get _hasPushedPages => _navigatorKey.currentState?.canPop() ?? false;
|
||||
|
||||
void _close() => Navigator.of(context).pop();
|
||||
|
||||
void _handlePop() {
|
||||
if (_hasPushedPages) {
|
||||
_navigatorKey.currentState!.pop();
|
||||
return;
|
||||
}
|
||||
(widget.onExit ?? _close)();
|
||||
}
|
||||
|
||||
Future<void> _handleDismiss() async {
|
||||
final onDismiss = widget.onDismiss;
|
||||
if (onDismiss == null) {
|
||||
_close();
|
||||
return;
|
||||
}
|
||||
await onDismiss(_hasPushedPages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sheetProvider = SheetProvider.of(context)!;
|
||||
final sheet = PagedSheet(
|
||||
child: SheetPagesNavigator(
|
||||
key: _navigatorKey,
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
PagedSheetRoute(builder: widget.builder),
|
||||
],
|
||||
),
|
||||
);
|
||||
return CommonPopScope(
|
||||
onPop: (_) async {
|
||||
_handlePop();
|
||||
return false;
|
||||
},
|
||||
child: sheetProvider.copyWith(
|
||||
nestedNavigatorPop: ([data]) => Navigator.of(context).pop(data),
|
||||
// A bottom sheet's route takes the taps outside it and the drag away.
|
||||
child: sheetProvider.type == SheetType.bottomSheet
|
||||
? sheet
|
||||
: SizedBox(
|
||||
width: sheetProvider.type == SheetType.sideSheet ? 400 : null,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(_handleDismiss()),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: sheet,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+859
-251
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Holds list refreshes while the page's route is pushed, popped, covered,
|
||||
/// uncovered or dragged back, and replays the latest one once it settles.
|
||||
mixin RouteMotionHoldMixin<T extends StatefulWidget> on State<T> {
|
||||
ModalRoute<Object?>? _route;
|
||||
ValueListenable<bool>? _userGesture;
|
||||
VoidCallback? _heldUpdate;
|
||||
|
||||
bool get _isRouteMoving {
|
||||
final route = _route;
|
||||
if (route == null) {
|
||||
return false;
|
||||
}
|
||||
return (route.animation?.isAnimating ?? false) ||
|
||||
(route.secondaryAnimation?.isAnimating ?? false) ||
|
||||
(_userGesture?.value ?? false);
|
||||
}
|
||||
|
||||
void updateWhenRouteSettled(VoidCallback update) {
|
||||
if (_isRouteMoving) {
|
||||
_heldUpdate = update;
|
||||
return;
|
||||
}
|
||||
_heldUpdate = null;
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
if (identical(route, _route)) {
|
||||
return;
|
||||
}
|
||||
_detach();
|
||||
_route = route;
|
||||
_userGesture = route?.navigator?.userGestureInProgressNotifier;
|
||||
route?.animation?.addStatusListener(_handleStatus);
|
||||
route?.secondaryAnimation?.addStatusListener(_handleStatus);
|
||||
_userGesture?.addListener(_releaseHeld);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detach();
|
||||
_heldUpdate = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _detach() {
|
||||
_route?.animation?.removeStatusListener(_handleStatus);
|
||||
_route?.secondaryAnimation?.removeStatusListener(_handleStatus);
|
||||
_userGesture?.removeListener(_releaseHeld);
|
||||
}
|
||||
|
||||
void _handleStatus(AnimationStatus _) => _releaseHeld();
|
||||
|
||||
void _releaseHeld() {
|
||||
final update = _heldUpdate;
|
||||
if (update == null || !mounted || _isRouteMoving) {
|
||||
return;
|
||||
}
|
||||
_heldUpdate = null;
|
||||
update();
|
||||
}
|
||||
}
|
||||
+881
-156
File diff suppressed because it is too large
Load Diff
+131
-19
@@ -162,14 +162,21 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
|
||||
// Material scrollbar defaults: 48px min thumb length, no main-axis
|
||||
// margin. Reversed lists place the thumb at (1 - fraction).
|
||||
final thumbExtent = max(48.0, track * fractionVisible).clamp(0.0, track);
|
||||
final thumbFraction = axisDirectionIsReversed(metrics.axisDirection)
|
||||
? 1 - fraction
|
||||
: fraction;
|
||||
return padding.top +
|
||||
(1 - fraction) * (track - thumbExtent) +
|
||||
thumbFraction * (track - thumbExtent) +
|
||||
thumbExtent / 2;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = BottomInsetScope.of(context);
|
||||
final behavior = ScrollConfiguration.of(context);
|
||||
final barPadding = behavior is BaseScrollBehavior
|
||||
? behavior.scrollbarPadding
|
||||
: EdgeInsets.zero;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: LayoutBuilder(
|
||||
@@ -192,16 +199,21 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
|
||||
top = _thumbCenter(
|
||||
metrics,
|
||||
constraints.maxHeight,
|
||||
MediaQuery.paddingOf(context),
|
||||
MediaQuery.paddingOf(context) + barPadding,
|
||||
).clamp(half, max(half, limit - half));
|
||||
label = widget.hintBuilder(fraction);
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
// This is the list's only bar; the ambient behavior adds none.
|
||||
CommonScrollBar(
|
||||
controller: widget.controller,
|
||||
child: widget.child,
|
||||
padding: barPadding,
|
||||
child: ScrollConfiguration(
|
||||
behavior: const HiddenBarScrollBehavior(),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
if (label != null)
|
||||
Positioned(
|
||||
@@ -299,6 +311,7 @@ class ScrollToEndBox<T> extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool enable;
|
||||
final VoidCallback? onCancelToEnd;
|
||||
final VoidCallback? onResumeToEnd;
|
||||
|
||||
const ScrollToEndBox({
|
||||
super.key,
|
||||
@@ -306,6 +319,7 @@ class ScrollToEndBox<T> extends StatefulWidget {
|
||||
required this.controller,
|
||||
required this.dataSource,
|
||||
this.onCancelToEnd,
|
||||
this.onResumeToEnd,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
@@ -314,8 +328,40 @@ class ScrollToEndBox<T> extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ScrollToEndBoxState<T> extends State<ScrollToEndBox<T>> {
|
||||
bool _isAtEnd(ScrollPosition position) =>
|
||||
(position.maxScrollExtent - position.pixels).abs() <
|
||||
double? _viewportDimension;
|
||||
|
||||
/// Moving the position would end the user's drag or fling under them.
|
||||
bool _userScrolling = false;
|
||||
bool _followDeferred = false;
|
||||
ValueListenable<bool>? _sheetSettling;
|
||||
|
||||
bool get _followPaused => _userScrolling || (_sheetSettling?.value ?? false);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final settling = SheetSettlingScope.of(context);
|
||||
if (settling != _sheetSettling) {
|
||||
_sheetSettling?.removeListener(_resumeFollow);
|
||||
_sheetSettling = settling?..addListener(_resumeFollow);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sheetSettling?.removeListener(_resumeFollow);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resumeFollow() {
|
||||
if (_followDeferred && !_followPaused) {
|
||||
_followDeferred = false;
|
||||
_scheduleScrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAtEnd(ScrollMetrics metrics) =>
|
||||
(metrics.maxScrollExtent - metrics.pixels).abs() <
|
||||
precisionErrorTolerance;
|
||||
|
||||
void _scheduleScrollToEnd() {
|
||||
@@ -324,23 +370,50 @@ class _ScrollToEndBoxState<T> extends State<ScrollToEndBox<T>> {
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleJumpToEnd() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !widget.enable || !widget.controller.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (_followPaused) {
|
||||
_followDeferred = true;
|
||||
return;
|
||||
}
|
||||
final position = widget.controller.position;
|
||||
if (!_isAtEnd(position)) {
|
||||
widget.controller.jumpTo(position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scrollToEnd() async {
|
||||
if (!mounted || !widget.controller.hasClients) {
|
||||
if (!mounted || !widget.enable || !widget.controller.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (_followPaused) {
|
||||
_followDeferred = true;
|
||||
return;
|
||||
}
|
||||
final position = widget.controller.position;
|
||||
if (_isAtEnd(position)) {
|
||||
return;
|
||||
}
|
||||
await widget.controller.animateTo(
|
||||
position.maxScrollExtent,
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
if (position.maxScrollExtent - position.pixels >
|
||||
position.viewportDimension) {
|
||||
widget.controller.jumpTo(position.maxScrollExtent);
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
} else {
|
||||
await widget.controller.animateTo(
|
||||
position.maxScrollExtent,
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
// Lazy lists refine maxScrollExtent while the animation runs, so the
|
||||
// target captured at start can land short of the real end.
|
||||
if (mounted &&
|
||||
widget.enable &&
|
||||
!_followPaused &&
|
||||
widget.controller.hasClients &&
|
||||
!_isAtEnd(position)) {
|
||||
widget.controller.jumpTo(position.maxScrollExtent);
|
||||
@@ -369,16 +442,55 @@ class _ScrollToEndBoxState<T> extends State<ScrollToEndBox<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleMetricsNotification(ScrollMetricsNotification notification) {
|
||||
if (notification.depth != 0) {
|
||||
return false;
|
||||
}
|
||||
final viewportDimension = notification.metrics.viewportDimension;
|
||||
final resized =
|
||||
_viewportDimension != null && _viewportDimension != viewportDimension;
|
||||
_viewportDimension = viewportDimension;
|
||||
if (resized && widget.enable && !_isAtEnd(notification.metrics)) {
|
||||
_scheduleJumpToEnd();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification.depth != 0) {
|
||||
return false;
|
||||
}
|
||||
if (notification is ScrollStartNotification &&
|
||||
notification.dragDetails != null) {
|
||||
_userScrolling = true;
|
||||
}
|
||||
if (notification is ScrollEndNotification && _userScrolling) {
|
||||
_userScrolling = false;
|
||||
_resumeFollow();
|
||||
}
|
||||
if (notification is UserScrollNotification) {
|
||||
if (notification.direction == ScrollDirection.forward) {
|
||||
_followDeferred = false;
|
||||
widget.onCancelToEnd?.call();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!widget.enable &&
|
||||
notification is ScrollEndNotification &&
|
||||
_isAtEnd(notification.metrics)) {
|
||||
widget.onResumeToEnd?.call();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.direction == ScrollDirection.forward) {
|
||||
widget.onCancelToEnd?.call();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: widget.child,
|
||||
return NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: _handleMetricsNotification,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+174
-287
@@ -1,11 +1,14 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/models/common.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'scaffold.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'sheet_navigator.dart';
|
||||
import 'side_sheet.dart';
|
||||
import 'snap_sheet.dart';
|
||||
|
||||
@immutable
|
||||
class SheetProps {
|
||||
@@ -14,7 +17,6 @@ class SheetProps {
|
||||
final bool isScrollControlled;
|
||||
final bool useSafeArea;
|
||||
final Color? backgroundColor;
|
||||
final bool blur;
|
||||
|
||||
const SheetProps({
|
||||
this.maxWidth,
|
||||
@@ -22,7 +24,6 @@ class SheetProps {
|
||||
this.backgroundColor,
|
||||
this.useSafeArea = true,
|
||||
this.isScrollControlled = false,
|
||||
this.blur = true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,54 +31,58 @@ class SheetProps {
|
||||
class ExtendProps {
|
||||
final double? maxWidth;
|
||||
final bool useSafeArea;
|
||||
final bool blur;
|
||||
final bool forceFull;
|
||||
|
||||
const ExtendProps({
|
||||
this.maxWidth,
|
||||
this.useSafeArea = true,
|
||||
this.blur = true,
|
||||
this.forceFull = false,
|
||||
});
|
||||
}
|
||||
|
||||
enum SheetType { page, bottomSheet, sideSheet }
|
||||
|
||||
/// Material's cap for a bottom sheet that does not control its own scrolling.
|
||||
const shortSheetMaxHeight = 9 / 16;
|
||||
|
||||
Future<T?> showSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
SheetProps props = const SheetProps(),
|
||||
}) {
|
||||
final isMobile = context.isMobileView;
|
||||
return switch (isMobile) {
|
||||
true => showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: props.isScrollControlled,
|
||||
builder: (_) {
|
||||
return SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: builder(context),
|
||||
);
|
||||
},
|
||||
backgroundColor: props.backgroundColor,
|
||||
showDragHandle: false,
|
||||
useSafeArea: props.useSafeArea,
|
||||
),
|
||||
false => showModalSideSheet<T>(
|
||||
useSafeArea: props.useSafeArea,
|
||||
isScrollControlled: props.isScrollControlled,
|
||||
context: context,
|
||||
backgroundColor: props.backgroundColor,
|
||||
constraints: BoxConstraints(maxWidth: props.maxWidth ?? 360),
|
||||
filter: props.blur ? commonFilter : null,
|
||||
builder: (_) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
child: builder(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
final barrierColor = context.colorScheme.modalScrim;
|
||||
if (isMobile) {
|
||||
final navigator = sheetNavigatorOf(context);
|
||||
return navigator.push(
|
||||
SnapSheetRoute<T>(
|
||||
builder: (sheetContext, _) => builder(sheetContext),
|
||||
fitMaxHeight: props.isScrollControlled ? 1 : shortSheetMaxHeight,
|
||||
sheetBarrierColor: barrierColor,
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
).modalBarrierDismissLabel,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return showModalSideSheet<T>(
|
||||
useSafeArea: props.useSafeArea,
|
||||
isScrollControlled: props.isScrollControlled,
|
||||
context: context,
|
||||
backgroundColor: props.backgroundColor,
|
||||
constraints: BoxConstraints(maxWidth: props.maxWidth ?? 360),
|
||||
barrierColor: barrierColor,
|
||||
builder: (sheetContext) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
child: builder(sheetContext),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> showExtend<T>(
|
||||
@@ -95,7 +100,7 @@ Future<T?> showExtend<T>(
|
||||
useSafeArea: props.useSafeArea,
|
||||
context: context,
|
||||
constraints: BoxConstraints(maxWidth: props.maxWidth ?? 360),
|
||||
filter: props.blur ? commonFilter : null,
|
||||
barrierColor: context.colorScheme.modalScrim,
|
||||
builder: (context) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
@@ -106,264 +111,146 @@ Future<T?> showExtend<T>(
|
||||
};
|
||||
}
|
||||
|
||||
class AdaptiveSheetScaffold extends StatefulWidget {
|
||||
final Widget body;
|
||||
final String title;
|
||||
final bool sheetTransparentToolBar;
|
||||
final bool? centerTitle;
|
||||
final List<IconButtonData> actions;
|
||||
final VoidCallback? backAction;
|
||||
/// Opens a sheet the reader can drag between [detents], starting at the
|
||||
/// shortest. Where a sheet cannot have detents the
|
||||
/// content keeps its own scroll controller, [initialScrollOffset] is the
|
||||
/// caller's own business, and [controller] stays detached.
|
||||
///
|
||||
/// The sheet follows the view across the mobile breakpoint: it reopens in the
|
||||
/// other form, and the result is whichever form the reader closes.
|
||||
Future<T?> showSnapSheet<T>(
|
||||
BuildContext context, {
|
||||
required SnapSheetBuilder builder,
|
||||
double initialScrollOffset = 0,
|
||||
List<double> detents = snapSheetDetents,
|
||||
double? collapsedDetent,
|
||||
SnapSheetController? controller,
|
||||
}) {
|
||||
final completer = Completer<T?>();
|
||||
|
||||
const AdaptiveSheetScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
this.sheetTransparentToolBar = false,
|
||||
this.centerTitle,
|
||||
this.actions = const [],
|
||||
this.backAction,
|
||||
void open({required bool isMobile}) {
|
||||
var crossed = false;
|
||||
var popped = false;
|
||||
|
||||
// The mobile layout drops a desktop page's navigator, sheet and all.
|
||||
void reopenIfDropped() {
|
||||
if (crossed || popped) {
|
||||
return;
|
||||
}
|
||||
crossed = true;
|
||||
if (!isMobile) {
|
||||
controller?.detachSide();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
open(isMobile: context.isMobileView);
|
||||
} else {
|
||||
completer.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget home(BuildContext sheetContext, ScrollController? controller) {
|
||||
return _SnapSheetHome(
|
||||
isMobile: isMobile,
|
||||
onCross: () {
|
||||
if (crossed || !sheetContext.mounted || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (ModalRoute.of(sheetContext)?.isCurrent != true) {
|
||||
return;
|
||||
}
|
||||
crossed = true;
|
||||
Navigator.of(sheetContext).pop();
|
||||
open(isMobile: !isMobile);
|
||||
},
|
||||
onDispose: reopenIfDropped,
|
||||
child: builder(sheetContext, controller),
|
||||
);
|
||||
}
|
||||
|
||||
final barrierColor = context.colorScheme.modalScrim;
|
||||
final Future<T?> closed;
|
||||
if (isMobile) {
|
||||
final navigator = sheetNavigatorOf(context);
|
||||
closed = navigator.push(
|
||||
SnapSheetRoute<T>(
|
||||
builder: home,
|
||||
detents: detents,
|
||||
collapsedDetent: collapsedDetent,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
sheetController: controller,
|
||||
sheetBarrierColor: barrierColor,
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
).modalBarrierDismissLabel,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
controller?.attachSide();
|
||||
closed = showModalSideSheet<T>(
|
||||
context: context,
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
barrierColor: barrierColor,
|
||||
aside: controller?.aside,
|
||||
builder: (context) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
child: home(context, null),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
unawaited(
|
||||
closed.then((value) {
|
||||
popped = true;
|
||||
if (!isMobile) {
|
||||
controller?.detachSide();
|
||||
}
|
||||
if (!crossed) {
|
||||
completer.complete(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
open(isMobile: context.isMobileView);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
class _SnapSheetHome extends ConsumerStatefulWidget {
|
||||
const _SnapSheetHome({
|
||||
required this.isMobile,
|
||||
required this.onCross,
|
||||
required this.onDispose,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool isMobile;
|
||||
final VoidCallback onCross;
|
||||
final VoidCallback onDispose;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<AdaptiveSheetScaffold> createState() => _AdaptiveSheetScaffoldState();
|
||||
ConsumerState<_SnapSheetHome> createState() => _SnapSheetHomeState();
|
||||
}
|
||||
|
||||
class _AdaptiveSheetScaffoldState extends State<AdaptiveSheetScaffold> {
|
||||
IconData get backIconData {
|
||||
if (kIsWeb) {
|
||||
return Icons.arrow_back;
|
||||
}
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
return Icons.arrow_back;
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return Icons.arrow_back_ios_new_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
class _SnapSheetHomeState extends ConsumerState<_SnapSheetHome> {
|
||||
@override
|
||||
void didUpdateWidget(covariant AdaptiveSheetScaffold oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.backAction != widget.backAction) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildIconButton(IconButtonData data, {required bool filled}) {
|
||||
return _SheetIconButton(data: data, filled: filled);
|
||||
}
|
||||
|
||||
IconButtonData _popButtonData(
|
||||
BuildContext context, {
|
||||
required bool useCloseIcon,
|
||||
}) {
|
||||
if (useCloseIcon) {
|
||||
return IconButtonData(
|
||||
icon: Icons.close,
|
||||
onPressed: context.safeNestedPop,
|
||||
tooltip: context.appLocalizations.close,
|
||||
);
|
||||
}
|
||||
return IconButtonData(
|
||||
icon: backIconData,
|
||||
onPressed: widget.backAction ?? () => Navigator.of(context).pop(),
|
||||
tooltip: context.appLocalizations.back,
|
||||
);
|
||||
void dispose() {
|
||||
widget.onDispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sheetProvider = SheetProvider.of(context);
|
||||
final type = sheetProvider?.type ?? SheetType.page;
|
||||
final isBottomSheet = type == SheetType.bottomSheet;
|
||||
final centerTitle = widget.centerTitle ?? isBottomSheet;
|
||||
|
||||
if (type == SheetType.page) {
|
||||
return CommonScaffold(
|
||||
title: widget.title,
|
||||
centerTitle: centerTitle,
|
||||
actions: [
|
||||
for (final data in widget.actions)
|
||||
_buildIconButton(data, filled: false),
|
||||
],
|
||||
body: widget.body,
|
||||
);
|
||||
if (ref.watch(isMobileViewProvider) != widget.isMobile) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onCross());
|
||||
}
|
||||
|
||||
final nestedNavigatorPop = sheetProvider?.nestedNavigatorPop;
|
||||
final route = ModalRoute.of(context);
|
||||
final useCloseIcon =
|
||||
nestedNavigatorPop == null || route?.impliesAppBarDismissal == false;
|
||||
final actions = [
|
||||
for (final data in widget.actions)
|
||||
_buildIconButton(data, filled: isBottomSheet),
|
||||
];
|
||||
final popButton = _buildIconButton(
|
||||
_popButtonData(context, useCloseIcon: useCloseIcon),
|
||||
filled: isBottomSheet,
|
||||
);
|
||||
final popAsSuffix = useCloseIcon && actions.isEmpty;
|
||||
final backgroundColor = isBottomSheet
|
||||
? context.colorScheme.surfaceContainerLow
|
||||
: context.colorScheme.surface;
|
||||
final appBar = AppBar(
|
||||
backgroundColor: backgroundColor,
|
||||
forceMaterialTransparency: isBottomSheet,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: popAsSuffix ? null : Center(child: popButton),
|
||||
centerTitle: centerTitle,
|
||||
toolbarHeight: isBottomSheet ? 48 : null,
|
||||
title: Text(widget.title),
|
||||
titleTextStyle: isBottomSheet
|
||||
? context.textTheme.titleLarge?.adjustSize(-4)
|
||||
: null,
|
||||
actions: genActions(popAsSuffix ? [popButton] : actions),
|
||||
);
|
||||
if (!isBottomSheet) {
|
||||
return CommonScaffold(appBar: appBar, body: widget.body);
|
||||
}
|
||||
final sheetAppBar = _SheetToolBar(appBar: appBar);
|
||||
return ClipRSuperellipse(
|
||||
borderRadius: AppRadius.top(AppCorner.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!widget.sheetTransparentToolBar) ...[
|
||||
sheetAppBar,
|
||||
Flexible(
|
||||
child: ScrollConfiguration(
|
||||
behavior: const ShowBarScrollBehavior(),
|
||||
child: widget.body,
|
||||
),
|
||||
),
|
||||
] else
|
||||
Flexible(
|
||||
child: _TransparentToolBarBody(
|
||||
backgroundColor: backgroundColor,
|
||||
toolBar: sheetAppBar,
|
||||
body: widget.body,
|
||||
),
|
||||
),
|
||||
SizedBox(height: MediaQuery.viewInsetsOf(context).bottom),
|
||||
SizedBox(height: MediaQuery.viewPaddingOf(context).bottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetIconButton extends StatelessWidget {
|
||||
const _SheetIconButton({required this.data, required this.filled});
|
||||
|
||||
static final _style = IconButton.styleFrom(
|
||||
visualDensity: VisualDensity.standard,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
|
||||
final IconButtonData data;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(data.icon);
|
||||
if (filled) {
|
||||
return IconButton.filledTonal(
|
||||
tooltip: data.tooltip,
|
||||
onPressed: data.onPressed,
|
||||
style: _style,
|
||||
icon: icon,
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
tooltip: data.tooltip,
|
||||
onPressed: data.onPressed,
|
||||
style: _style,
|
||||
icon: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetToolBar extends StatelessWidget {
|
||||
const _SheetToolBar({required this.appBar});
|
||||
|
||||
static const _handleSize = Size(28, 4);
|
||||
|
||||
final Widget appBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: _handleSize.height,
|
||||
width: _handleSize.width,
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
shape: AppShape.all(_handleSize.height / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: appBar,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransparentToolBarBody extends StatelessWidget {
|
||||
const _TransparentToolBarBody({
|
||||
required this.backgroundColor,
|
||||
required this.toolBar,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Widget toolBar;
|
||||
final Widget body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ScrollConfiguration(
|
||||
behavior: const ShowBarScrollBehavior(
|
||||
scrollbarPadding: EdgeInsets.only(top: sheetAppBarHeight),
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: sheetAppBarHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.5, 1],
|
||||
colors: [
|
||||
backgroundColor.opacity60,
|
||||
backgroundColor.opacity60,
|
||||
backgroundColor.opacity0,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(alignment: Alignment.topCenter, child: toolBar),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
import 'inherited.dart';
|
||||
|
||||
class SheetDragHandle extends StatelessWidget {
|
||||
const SheetDragHandle({super.key});
|
||||
|
||||
static const height = _topPadding + _thickness;
|
||||
|
||||
static const _topPadding = 6.0;
|
||||
static const _thickness = 4.0;
|
||||
static const _width = 28.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: _topPadding),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
height: _thickness,
|
||||
width: _width,
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
shape: AppShape.all(_thickness / 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag handle over [appBar], together exactly [sheetAppBarHeight] tall.
|
||||
class SheetToolBar extends StatelessWidget {
|
||||
const SheetToolBar({super.key, required this.appBar});
|
||||
|
||||
static const _trailing =
|
||||
sheetAppBarHeight - SheetDragHandle.height - sheetToolbarHeight;
|
||||
|
||||
final Widget appBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SheetDragHandle(),
|
||||
appBar,
|
||||
const SizedBox(height: _trailing),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades the surface behind [child] toward its foot so content reads through,
|
||||
/// easing out over [overhang] past the foot so the fade shows no edge.
|
||||
class FloatingHeader extends StatelessWidget {
|
||||
const FloatingHeader({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
this.fadeStart = 0.5,
|
||||
this.overhang = 0,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
static const _alpha = 0.9;
|
||||
static const _fadeSteps = 8;
|
||||
|
||||
final Color backgroundColor;
|
||||
final double fadeStart;
|
||||
final double overhang;
|
||||
final Widget child;
|
||||
|
||||
Gradient _scrimOf(double height) {
|
||||
final start = (height - overhang) * fadeStart / height;
|
||||
final stops = <double>[0];
|
||||
final colors = <Color>[backgroundColor.withValues(alpha: _alpha)];
|
||||
for (var i = 0; i <= _fadeSteps; i++) {
|
||||
final t = i / _fadeSteps;
|
||||
stops.add(start + (1 - start) * t);
|
||||
colors.add(
|
||||
backgroundColor.withValues(
|
||||
alpha: _alpha * (1 - Curves.easeInOut.transform(t)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: stops,
|
||||
colors: colors,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -overhang,
|
||||
child: IgnorePointer(
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: _scrimOf(constraints.maxHeight),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats [header] over [body], fading it out so content reads through it
|
||||
/// as it scrolls past; [footer] sits on its own surface.
|
||||
class FloatingHeaderBody extends StatelessWidget {
|
||||
const FloatingHeaderBody({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
required this.header,
|
||||
required this.body,
|
||||
this.footer,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Widget header;
|
||||
final Widget body;
|
||||
final Widget? footer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ScrollConfiguration(
|
||||
behavior: const ShowBarScrollBehavior(
|
||||
scrollbarPadding: EdgeInsets.only(top: sheetAppBarHeight),
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: FloatingHeader(
|
||||
backgroundColor: backgroundColor,
|
||||
child: header,
|
||||
),
|
||||
),
|
||||
if (footer != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SheetOverhangLift(
|
||||
child: Material(type: MaterialType.transparency, child: footer),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds its child clear of the part of the sheet that hangs below the screen
|
||||
/// while the sheet is dragged below its shortest detent.
|
||||
class SheetOverhangLift extends SingleChildRenderObjectWidget {
|
||||
const SheetOverhangLift({super.key, super.child});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderSheetOverhangLift(overhang: SheetOverhangScope.of(context));
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
||||
(renderObject as _RenderSheetOverhangLift).overhang = SheetOverhangScope.of(
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderSheetOverhangLift extends RenderTransform {
|
||||
_RenderSheetOverhangLift({required ValueListenable<double>? overhang})
|
||||
: _overhang = overhang,
|
||||
super(transform: Matrix4.identity(), transformHitTests: true) {
|
||||
_overhang?.addListener(_syncTransform);
|
||||
}
|
||||
|
||||
ValueListenable<double>? _overhang;
|
||||
|
||||
set overhang(ValueListenable<double>? value) {
|
||||
if (_overhang == value) {
|
||||
return;
|
||||
}
|
||||
_overhang?.removeListener(_syncTransform);
|
||||
_overhang = value;
|
||||
_overhang?.addListener(_syncTransform);
|
||||
_syncTransform();
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
_syncTransform();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overhang?.removeListener(_syncTransform);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTransform() {
|
||||
transform = Matrix4.translationValues(0, -(_overhang?.value ?? 0), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A sheet's own navigator, which holds only the pages of that sheet.
|
||||
class SheetPagesNavigator extends Navigator {
|
||||
const SheetPagesNavigator({super.key, super.onGenerateInitialRoutes});
|
||||
}
|
||||
|
||||
bool isSheetPage(BuildContext context) =>
|
||||
Navigator.maybeOf(context)?.widget is SheetPagesNavigator;
|
||||
|
||||
/// A sheet opened from inside another sheet goes over it, sized by the space
|
||||
/// that sheet is shown in rather than squeezed among its pages.
|
||||
NavigatorState sheetNavigatorOf(BuildContext context) {
|
||||
var navigator = Navigator.of(context);
|
||||
while (navigator.widget is SheetPagesNavigator) {
|
||||
navigator = navigator.context.findAncestorStateOfType<NavigatorState>()!;
|
||||
}
|
||||
return navigator;
|
||||
}
|
||||
+48
-30
@@ -1,7 +1,7 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:fl_clash/common/color.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/sheet_navigator.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
@@ -362,28 +362,32 @@ class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
||||
);
|
||||
final String routeLabel = _getRouteLabel(localizations);
|
||||
|
||||
final route = widget.route;
|
||||
return AnimatedBuilder(
|
||||
animation: widget.route.animation!,
|
||||
child: SideSheet(
|
||||
animationController: widget.route._animationController,
|
||||
onClosing: () {
|
||||
if (widget.route.isCurrent) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
builder: widget.route.builder,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
elevation: widget.elevation,
|
||||
shape: widget.shape,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
constraints: widget.constraints,
|
||||
enableDrag: widget.enableDrag,
|
||||
showDragHandle: widget.showDragHandle,
|
||||
animation: route._presence,
|
||||
child: route.dragBackDetector(
|
||||
SideSheet(
|
||||
animationController: route._animationController,
|
||||
onClosing: () {
|
||||
if (route.isCurrent) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
builder: route.builder,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
elevation: widget.elevation,
|
||||
shape: widget.shape,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
constraints: widget.constraints,
|
||||
enableDrag: widget.enableDrag,
|
||||
showDragHandle: widget.showDragHandle,
|
||||
),
|
||||
),
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
final double animationValue = animationCurve.transform(
|
||||
widget.route.animation!.value,
|
||||
);
|
||||
final curve = route.isDragBackActive ? Curves.linear : animationCurve;
|
||||
final double animationValue =
|
||||
curve.transform(route.animation!.value) *
|
||||
(1 - (route.aside?.value ?? 0));
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
namesRoute: true,
|
||||
@@ -409,7 +413,7 @@ class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
class ModalSideSheetRoute<T> extends PopupRoute<T> {
|
||||
class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
ModalSideSheetRoute({
|
||||
required this.builder,
|
||||
this.capturedThemes,
|
||||
@@ -429,11 +433,18 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> {
|
||||
this.transitionAnimationController,
|
||||
this.anchorPoint,
|
||||
this.useSafeArea = false,
|
||||
super.filter,
|
||||
this.aside,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
/// Slides the sheet off, scrim and all, as it runs to 1 without closing it.
|
||||
final Animation<double>? aside;
|
||||
|
||||
late final Animation<double> _presence = aside == null
|
||||
? animation!
|
||||
: _SidePresence(animation!, aside!);
|
||||
|
||||
final CapturedThemes? capturedThemes;
|
||||
|
||||
final bool isScrollControlled;
|
||||
@@ -543,7 +554,7 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> {
|
||||
Widget buildModalBarrier() {
|
||||
if (barrierColor.a != 0 && !offstage) {
|
||||
assert(barrierColor != barrierColor.opacity0);
|
||||
final Animation<Color?> color = animation!.drive(
|
||||
final Animation<Color?> color = _presence.drive(
|
||||
ColorTween(
|
||||
begin: barrierColor.opacity0,
|
||||
end: barrierColor,
|
||||
@@ -588,20 +599,18 @@ Future<T?> showModalSideSheet<T>({
|
||||
RouteSettings? routeSettings,
|
||||
AnimationController? transitionAnimationController,
|
||||
Offset? anchorPoint,
|
||||
ImageFilter? filter,
|
||||
Animation<double>? aside,
|
||||
}) {
|
||||
assert(debugCheckHasMediaQuery(context));
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
|
||||
final NavigatorState navigator = Navigator.of(
|
||||
context,
|
||||
rootNavigator: useRootNavigator,
|
||||
);
|
||||
final navigator = useRootNavigator
|
||||
? Navigator.of(context, rootNavigator: true)
|
||||
: sheetNavigatorOf(context);
|
||||
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
|
||||
return navigator.push(
|
||||
ModalSideSheetRoute<T>(
|
||||
builder: builder,
|
||||
filter: filter,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
@@ -624,6 +633,15 @@ Future<T?> showModalSideSheet<T>({
|
||||
transitionAnimationController: transitionAnimationController,
|
||||
anchorPoint: anchorPoint,
|
||||
useSafeArea: useSafeArea,
|
||||
aside: aside,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SidePresence extends CompoundAnimation<double> {
|
||||
_SidePresence(Animation<double> entrance, Animation<double> aside)
|
||||
: super(first: entrance, next: aside);
|
||||
|
||||
@override
|
||||
double get value => first.value * (1 - next.value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _compactWidth = 48.0;
|
||||
const _expandedWidth = 220.0;
|
||||
const _itemSize = 40.0;
|
||||
const _itemGap = 4.0;
|
||||
const _toggleGap = 8.0;
|
||||
const _verticalInset = 4.0;
|
||||
const _maxSideInset = 12.0;
|
||||
const _iconSize = 24.0;
|
||||
const _labelGap = 12.0;
|
||||
const _indicatorWidth = 3.0;
|
||||
const _indicatorHeight = 16.0;
|
||||
const _expandDuration = Duration(milliseconds: 250);
|
||||
const _indicatorDuration = Duration(milliseconds: 320);
|
||||
final _itemShape = AppShape.all(10);
|
||||
|
||||
class SidebarDestination {
|
||||
const SidebarDestination({required this.glyph, required this.label});
|
||||
|
||||
final Glyph glyph;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Where the sidebar cannot widen in place, [onToggle] is null and the toggle
|
||||
/// opens the expanded pane over the content instead.
|
||||
///
|
||||
/// [windowControls] is the area native window buttons cover at the top start
|
||||
/// corner; the pane keeps it clear and never gets narrower than it.
|
||||
class NavigationSidebar extends StatefulWidget {
|
||||
const NavigationSidebar({
|
||||
super.key,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.expanded,
|
||||
required this.onSelected,
|
||||
this.onToggle,
|
||||
this.windowControls = Size.zero,
|
||||
});
|
||||
|
||||
final List<SidebarDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final bool expanded;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback? onToggle;
|
||||
final Size windowControls;
|
||||
|
||||
@override
|
||||
State<NavigationSidebar> createState() => _NavigationSidebarState();
|
||||
}
|
||||
|
||||
class _NavigationSidebarState extends State<NavigationSidebar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _overlayController = OverlayPortalController();
|
||||
late final AnimationController _overlayExpansion = AnimationController(
|
||||
vsync: this,
|
||||
duration: _expandDuration,
|
||||
);
|
||||
late final CurvedAnimation _overlayProgress = CurvedAnimation(
|
||||
parent: _overlayExpansion,
|
||||
curve: Easing.standard,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NavigationSidebar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.onToggle != null && _overlayController.isShowing) {
|
||||
_overlayExpansion.value = 0;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _hideIfDismissed());
|
||||
}
|
||||
}
|
||||
|
||||
void _handleToggle() {
|
||||
final onToggle = widget.onToggle;
|
||||
if (onToggle != null) {
|
||||
onToggle();
|
||||
} else if (_overlayExpansion.isForwardOrCompleted) {
|
||||
_closeOverlay();
|
||||
} else {
|
||||
_overlayController.show();
|
||||
_overlayExpansion.forward();
|
||||
}
|
||||
}
|
||||
|
||||
void _closeOverlay() {
|
||||
_overlayExpansion.reverse().whenCompleteOrCancel(_hideIfDismissed);
|
||||
}
|
||||
|
||||
void _hideIfDismissed() {
|
||||
if (mounted && _overlayExpansion.isDismissed) {
|
||||
_overlayController.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlayProgress.dispose();
|
||||
_overlayExpansion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safePadding = MediaQuery.paddingOf(context);
|
||||
return OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: _overlayController,
|
||||
overlayChildBuilder: (context, info) {
|
||||
return _SidebarOverlay(
|
||||
anchor: MatrixUtils.transformRect(
|
||||
info.childPaintTransform,
|
||||
Offset.zero & info.childSize,
|
||||
),
|
||||
overlayWidth: info.overlaySize.width,
|
||||
progress: _overlayProgress,
|
||||
onDismiss: _closeOverlay,
|
||||
builder: (progress) => Padding(
|
||||
padding: safePadding,
|
||||
child: _SidebarPane(
|
||||
progress: progress,
|
||||
windowControls: widget.windowControls,
|
||||
expanded: true,
|
||||
destinations: widget.destinations,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onSelected: (index) {
|
||||
widget.onSelected(index);
|
||||
_closeOverlay();
|
||||
},
|
||||
onToggle: _closeOverlay,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SafeArea(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: widget.expanded ? 1.0 : 0.0),
|
||||
duration: _expandDuration,
|
||||
curve: Easing.standard,
|
||||
builder: (context, progress, _) => _SidebarPane(
|
||||
progress: progress,
|
||||
windowControls: widget.windowControls,
|
||||
expanded: widget.expanded,
|
||||
destinations: widget.destinations,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onSelected: widget.onSelected,
|
||||
onToggle: _handleToggle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarOverlay extends StatelessWidget {
|
||||
const _SidebarOverlay({
|
||||
required this.anchor,
|
||||
required this.overlayWidth,
|
||||
required this.progress,
|
||||
required this.onDismiss,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final Rect anchor;
|
||||
final double overlayWidth;
|
||||
final Animation<double> progress;
|
||||
final VoidCallback onDismiss;
|
||||
final Widget Function(double progress) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLtr = Directionality.of(context) == TextDirection.ltr;
|
||||
final colorScheme = context.colorScheme;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onDismiss,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: isLtr ? anchor.left : null,
|
||||
right: isLtr ? null : overlayWidth - anchor.right,
|
||||
top: anchor.top,
|
||||
height: anchor.height,
|
||||
child: Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (_) {
|
||||
onDismiss();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: Material(
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: BorderDirectional(
|
||||
end: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: progress,
|
||||
builder: (_, _) => builder(progress.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarColors {
|
||||
_SidebarColors(ColorScheme scheme)
|
||||
: selectedFill = scheme.onSurface.withValues(alpha: 0.08),
|
||||
label = scheme.onSurface,
|
||||
icon = scheme.onSurfaceVariant,
|
||||
indicator = scheme.primary,
|
||||
overlay = WidgetStateProperty.fromMap({
|
||||
WidgetState.pressed: scheme.onSurface.withValues(alpha: 0.06),
|
||||
WidgetState.focused: scheme.onSurface.withValues(alpha: 0.1),
|
||||
WidgetState.hovered: scheme.onSurface.withValues(alpha: 0.04),
|
||||
WidgetState.any: Colors.transparent,
|
||||
});
|
||||
|
||||
final Color selectedFill;
|
||||
final Color label;
|
||||
final Color icon;
|
||||
final Color indicator;
|
||||
final WidgetStateProperty<Color> overlay;
|
||||
}
|
||||
|
||||
class _SidebarPane extends StatelessWidget {
|
||||
const _SidebarPane({
|
||||
required this.progress,
|
||||
required this.windowControls,
|
||||
required this.expanded,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Size windowControls;
|
||||
final bool expanded;
|
||||
final List<SidebarDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = _SidebarColors(context.colorScheme);
|
||||
final fontSize = context.textTheme.bodyMedium?.fontSize ?? 14;
|
||||
final labelOpacity = ((progress - 0.4) / 0.6).clamp(0.0, 1.0);
|
||||
final compactWidth = math.max(_compactWidth, windowControls.width);
|
||||
final sideInset = math.min(_maxSideInset, (compactWidth - _itemSize) / 2);
|
||||
final itemWidth = compactWidth - sideInset * 2;
|
||||
final iconInset = (itemWidth - _iconSize) / 2;
|
||||
final itemHeight =
|
||||
_itemSize + MediaQuery.textScalerOf(context).scale(fontSize) - fontSize;
|
||||
final rowWidth = _expandedWidth - sideInset * 2;
|
||||
final labelStyle = context.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.label,
|
||||
);
|
||||
final labelWidth = rowWidth - iconInset - _iconSize - _labelGap * 2;
|
||||
String rowTooltip(String label) =>
|
||||
expanded &&
|
||||
progress == 1 &&
|
||||
_fitsOneLine(context, label, labelStyle, labelWidth)
|
||||
? ''
|
||||
: label;
|
||||
return SizedBox(
|
||||
width: lerpDouble(compactWidth, _expandedWidth, progress),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: windowControls.height + _verticalInset),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: _SidebarButton(
|
||||
colors: colors,
|
||||
height: itemHeight,
|
||||
sideInset: sideInset,
|
||||
tooltip: expanded
|
||||
? context.appLocalizations.showLess
|
||||
: context.appLocalizations.showMore,
|
||||
tooltipIsLabel: true,
|
||||
onTap: onToggle,
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
child: Center(child: GlyphIcon(AppGlyphs.sidebar(progress))),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _toggleGap),
|
||||
Expanded(
|
||||
child: ScrollConfiguration(
|
||||
behavior: const HiddenBarScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (index, destination) in destinations.indexed)
|
||||
_SidebarButton(
|
||||
key: ValueKey(destination.label),
|
||||
colors: colors,
|
||||
height: itemHeight,
|
||||
sideInset: sideInset,
|
||||
selected: index == selectedIndex,
|
||||
tooltip: rowTooltip(destination.label),
|
||||
onTap: () => onSelected(index),
|
||||
child: _DestinationRow(
|
||||
destination: destination,
|
||||
selected: index == selectedIndex,
|
||||
width: rowWidth,
|
||||
iconInset: iconInset,
|
||||
style: labelStyle,
|
||||
labelOpacity: labelOpacity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (selectedIndex < destinations.length)
|
||||
_SelectionIndicator(
|
||||
color: colors.indicator,
|
||||
index: selectedIndex,
|
||||
start: sideInset,
|
||||
stride: itemHeight + _itemGap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _verticalInset),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarButton extends StatelessWidget {
|
||||
const _SidebarButton({
|
||||
super.key,
|
||||
required this.colors,
|
||||
required this.height,
|
||||
required this.sideInset,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
this.selected,
|
||||
this.tooltipIsLabel = false,
|
||||
});
|
||||
|
||||
final _SidebarColors colors;
|
||||
final double height;
|
||||
final double sideInset;
|
||||
final bool? selected;
|
||||
final String tooltip;
|
||||
final bool tooltipIsLabel;
|
||||
final VoidCallback onTap;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: sideInset,
|
||||
vertical: _itemGap / 2,
|
||||
),
|
||||
child: Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: Material(
|
||||
color: selected == true ? colors.selectedFill : Colors.transparent,
|
||||
shape: _itemShape,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: _itemShape,
|
||||
mouseCursor: SystemMouseCursors.basic,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: colors.overlay,
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
excludeFromSemantics: !tooltipIsLabel,
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(size: _iconSize, color: colors.icon),
|
||||
child: SizedBox(height: height, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _fitsOneLine(
|
||||
BuildContext context,
|
||||
String text,
|
||||
TextStyle? style,
|
||||
double width,
|
||||
) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
locale: Localizations.maybeLocaleOf(context),
|
||||
)..layout(maxWidth: width);
|
||||
final fits = !painter.didExceedMaxLines;
|
||||
painter.dispose();
|
||||
return fits;
|
||||
}
|
||||
|
||||
/// The row lays out at the expanded width whatever the pane's current width,
|
||||
/// so the label is clipped rather than reflowed while the pane animates.
|
||||
class _DestinationRow extends StatelessWidget {
|
||||
const _DestinationRow({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
required this.width,
|
||||
required this.iconInset,
|
||||
required this.style,
|
||||
required this.labelOpacity,
|
||||
});
|
||||
|
||||
final SidebarDestination destination;
|
||||
final bool selected;
|
||||
final double width;
|
||||
final double iconInset;
|
||||
final TextStyle? style;
|
||||
final double labelOpacity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minWidth: width,
|
||||
maxWidth: width,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: iconInset),
|
||||
AnimatedGlyph(glyph: destination.glyph, filled: selected),
|
||||
const SizedBox(width: _labelGap),
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: labelOpacity,
|
||||
alwaysIncludeSemantics: true,
|
||||
child: Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _labelGap),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The indicator moves by stretching toward the new row before its trailing
|
||||
/// edge catches up, rather than sliding at a fixed size.
|
||||
class _SelectionIndicator extends StatefulWidget {
|
||||
const _SelectionIndicator({
|
||||
required this.color,
|
||||
required this.index,
|
||||
required this.start,
|
||||
required this.stride,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final int index;
|
||||
final double start;
|
||||
final double stride;
|
||||
|
||||
@override
|
||||
State<_SelectionIndicator> createState() => _SelectionIndicatorState();
|
||||
}
|
||||
|
||||
class _SelectionIndicatorState extends State<_SelectionIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _indicatorDuration,
|
||||
value: 1,
|
||||
);
|
||||
late int _from = widget.index;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SelectionIndicator oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index != widget.index) {
|
||||
_from = oldWidget.index;
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _topOf(int index) =>
|
||||
index * widget.stride + (widget.stride - _indicatorHeight) / 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final t = _controller.value;
|
||||
final from = _topOf(_from);
|
||||
final to = _topOf(widget.index);
|
||||
final lead = Curves.easeOutCubic.transform(math.min(1, t / 0.6));
|
||||
final trail = Curves.easeInOutCubic.transform(
|
||||
math.max(0, (t - 0.35) / 0.65),
|
||||
);
|
||||
final (top, bottom) = to >= from
|
||||
? (lerpDouble(from, to, trail)!, lerpDouble(from, to, lead)!)
|
||||
: (lerpDouble(from, to, lead)!, lerpDouble(from, to, trail)!);
|
||||
return PositionedDirectional(
|
||||
start: widget.start,
|
||||
top: top,
|
||||
width: _indicatorWidth,
|
||||
height: bottom - top + _indicatorHeight,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: widget.color,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+22
-26
@@ -18,10 +18,6 @@ const EdgeInsetsGeometry _kHorizontalItemPadding = EdgeInsets.symmetric(
|
||||
|
||||
const double _kThumbInset = 1;
|
||||
|
||||
const Radius _kCornerRadius = Radius.circular(AppCorner.sm + _kThumbInset);
|
||||
|
||||
const Radius _kThumbRadius = Radius.circular(AppCorner.sm);
|
||||
|
||||
const EdgeInsets _kThumbInsets = EdgeInsets.symmetric(horizontal: _kThumbInset);
|
||||
|
||||
const double _kMinSegmentedControlHeight = 28.0;
|
||||
@@ -46,6 +42,8 @@ const FontWeight _kHighlightedFontWeight = FontWeight.w600;
|
||||
|
||||
const Color _kDisabledContentColor = Color.fromARGB(115, 122, 122, 122);
|
||||
|
||||
const double _kFocusRingWidth = 2;
|
||||
|
||||
final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation(
|
||||
const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799),
|
||||
0,
|
||||
@@ -67,6 +65,7 @@ class CommonTabBar<T extends Object> extends StatefulWidget {
|
||||
this.disabledChildren = const <Never>{},
|
||||
this.groupValue,
|
||||
required this.thumbColor,
|
||||
this.thumbRadius = AppCorner.sm,
|
||||
this.padding = _kHorizontalItemPadding,
|
||||
this.backgroundColor,
|
||||
this.proportionalWidth = false,
|
||||
@@ -81,6 +80,7 @@ class CommonTabBar<T extends Object> extends StatefulWidget {
|
||||
final ValueChanged<T?> onValueChanged;
|
||||
final Color? backgroundColor;
|
||||
final Color thumbColor;
|
||||
final double thumbRadius;
|
||||
final bool proportionalWidth;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@@ -335,28 +335,21 @@ class _CommonTabBarState<T extends Object> extends State<CommonTabBar<T>>
|
||||
TextDirection.rtl when index == 0 => _SegmentLocation.rightmost,
|
||||
TextDirection.ltr || TextDirection.rtl => _SegmentLocation.inbetween,
|
||||
};
|
||||
final bool enabled = !widget.disabledChildren.contains(entry.key);
|
||||
children.add(
|
||||
Semantics(
|
||||
button: true,
|
||||
onTap: () {
|
||||
if (widget.disabledChildren.contains(entry.key)) {
|
||||
return;
|
||||
}
|
||||
widget.onValueChanged(entry.key);
|
||||
},
|
||||
inMutuallyExclusiveGroup: true,
|
||||
_FocusableSegment(
|
||||
enabled: enabled,
|
||||
selected: widget.groupValue == entry.key,
|
||||
child: MouseRegion(
|
||||
cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
child: _Segment<T>(
|
||||
key: ValueKey<T>(entry.key),
|
||||
highlighted: isHighlighted,
|
||||
pressed: pressed == entry.key,
|
||||
isDragging: isThumbDragging,
|
||||
enabled: !widget.disabledChildren.contains(entry.key),
|
||||
segmentLocation: segmentLocation,
|
||||
child: entry.value,
|
||||
),
|
||||
focusRadius: widget.thumbRadius,
|
||||
onActivate: () => widget.onValueChanged(entry.key),
|
||||
child: _Segment<T>(
|
||||
key: ValueKey<T>(entry.key),
|
||||
highlighted: isHighlighted,
|
||||
pressed: pressed == entry.key,
|
||||
isDragging: isThumbDragging,
|
||||
enabled: enabled,
|
||||
segmentLocation: segmentLocation,
|
||||
child: entry.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -383,8 +376,10 @@ class _CommonTabBarState<T extends Object> extends State<CommonTabBar<T>>
|
||||
clipBehavior: Clip.antiAlias,
|
||||
padding: widget.padding.resolve(Directionality.of(context)),
|
||||
decoration: ShapeDecoration(
|
||||
shape: const RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.all(_kCornerRadius),
|
||||
shape: RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.thumbRadius + _kThumbInset,
|
||||
),
|
||||
),
|
||||
color: widget.backgroundColor,
|
||||
),
|
||||
@@ -396,6 +391,7 @@ class _CommonTabBarState<T extends Object> extends State<CommonTabBar<T>>
|
||||
key: segmentedControlRenderWidgetKey,
|
||||
highlightedIndex: highlightedIndex,
|
||||
thumbColor: widget.thumbColor,
|
||||
thumbRadius: widget.thumbRadius,
|
||||
thumbScale: thumbScaleAnimation.value,
|
||||
state: this,
|
||||
children: children,
|
||||
|
||||
@@ -7,6 +7,7 @@ class _CommonTabBarRenderWidget<T extends Object>
|
||||
super.children,
|
||||
required this.highlightedIndex,
|
||||
required this.thumbColor,
|
||||
required this.thumbRadius,
|
||||
required this.thumbScale,
|
||||
required this.state,
|
||||
required this.proportionalWidth,
|
||||
@@ -14,6 +15,7 @@ class _CommonTabBarRenderWidget<T extends Object>
|
||||
|
||||
final int? highlightedIndex;
|
||||
final Color thumbColor;
|
||||
final double thumbRadius;
|
||||
final double thumbScale;
|
||||
final bool proportionalWidth;
|
||||
final _CommonTabBarState<T> state;
|
||||
@@ -23,6 +25,7 @@ class _CommonTabBarRenderWidget<T extends Object>
|
||||
return _RenderSegmentedControl<T>(
|
||||
highlightedIndex: highlightedIndex,
|
||||
thumbColor: thumbColor,
|
||||
thumbRadius: thumbRadius,
|
||||
thumbScale: thumbScale,
|
||||
proportionalWidth: proportionalWidth,
|
||||
state: state,
|
||||
@@ -37,6 +40,7 @@ class _CommonTabBarRenderWidget<T extends Object>
|
||||
assert(renderObject.state == state);
|
||||
renderObject
|
||||
..thumbColor = thumbColor
|
||||
..thumbRadius = thumbRadius
|
||||
..thumbScale = thumbScale
|
||||
..highlightedIndex = highlightedIndex
|
||||
..proportionalWidth = proportionalWidth;
|
||||
@@ -61,11 +65,13 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
|
||||
_RenderSegmentedControl({
|
||||
required int? highlightedIndex,
|
||||
required Color thumbColor,
|
||||
required double thumbRadius,
|
||||
required double thumbScale,
|
||||
required bool proportionalWidth,
|
||||
required this.state,
|
||||
}) : _highlightedIndex = highlightedIndex,
|
||||
_thumbColor = thumbColor,
|
||||
_thumbRadius = thumbRadius,
|
||||
_thumbScale = thumbScale,
|
||||
_proportionalWidth = proportionalWidth;
|
||||
|
||||
@@ -111,6 +117,17 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double get thumbRadius => _thumbRadius;
|
||||
double _thumbRadius;
|
||||
|
||||
set thumbRadius(double value) {
|
||||
if (_thumbRadius == value) {
|
||||
return;
|
||||
}
|
||||
_thumbRadius = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Color get thumbColor => _thumbColor;
|
||||
Color _thumbColor;
|
||||
|
||||
@@ -502,7 +519,7 @@ class _RenderSegmentedControl<T extends Object> extends RenderBox
|
||||
void _paintThumb(PaintingContext context, Offset offset, Rect thumbRect) {
|
||||
final RSuperellipse thumbRSuperellipse = RSuperellipse.fromRectAndRadius(
|
||||
thumbRect.shift(offset),
|
||||
_kThumbRadius,
|
||||
Radius.circular(thumbRadius),
|
||||
);
|
||||
|
||||
context.canvas.drawRSuperellipse(
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
part of 'tab.dart';
|
||||
|
||||
class _FocusableSegment extends StatefulWidget {
|
||||
const _FocusableSegment({
|
||||
required this.enabled,
|
||||
required this.selected,
|
||||
required this.focusRadius,
|
||||
required this.onActivate,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool selected;
|
||||
final double focusRadius;
|
||||
final VoidCallback onActivate;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_FocusableSegment> createState() => _FocusableSegmentState();
|
||||
}
|
||||
|
||||
class _FocusableSegmentState extends State<_FocusableSegment> {
|
||||
bool _focused = false;
|
||||
|
||||
void _activate() {
|
||||
if (!widget.enabled) {
|
||||
return;
|
||||
}
|
||||
widget.onActivate();
|
||||
}
|
||||
|
||||
Object? _handleActivateIntent(Intent intent) {
|
||||
_activate();
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleFocusHighlight(bool value) {
|
||||
if (_focused == value) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_focused = value;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
onTap: _activate,
|
||||
inMutuallyExclusiveGroup: true,
|
||||
selected: widget.selected,
|
||||
child: FocusableActionDetector(
|
||||
enabled: widget.enabled,
|
||||
mouseCursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onShowFocusHighlight: _handleFocusHighlight,
|
||||
actions: <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
onInvoke: _handleActivateIntent,
|
||||
),
|
||||
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
|
||||
onInvoke: _handleActivateIntent,
|
||||
),
|
||||
},
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.circular(widget.focusRadius),
|
||||
side: _focused && widget.enabled
|
||||
? BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: _kFocusRingWidth,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment<T> extends StatefulWidget {
|
||||
const _Segment({
|
||||
required ValueKey<T> key,
|
||||
|
||||
@@ -11,19 +11,21 @@ export 'dialog.dart';
|
||||
export 'disabled_mask.dart';
|
||||
export 'dismissible.dart';
|
||||
export 'donut_chart.dart';
|
||||
export 'drag_back.dart';
|
||||
export 'effect.dart';
|
||||
export 'fade_box.dart';
|
||||
export 'float_layout.dart';
|
||||
export 'focus.dart';
|
||||
export 'grid.dart';
|
||||
export 'icon.dart';
|
||||
export 'inherited.dart';
|
||||
export 'input.dart';
|
||||
export 'keep_scope.dart';
|
||||
export 'keyboard_inset_hold.dart';
|
||||
export 'keyed_animated_list.dart';
|
||||
export 'line_chart.dart';
|
||||
export 'list.dart';
|
||||
export 'loading.dart';
|
||||
export 'navigation_dock.dart';
|
||||
export 'notification.dart';
|
||||
export 'null_status.dart';
|
||||
export 'open_container.dart';
|
||||
@@ -31,11 +33,16 @@ export 'paged_sheet.dart';
|
||||
export 'palette.dart';
|
||||
export 'pop_scope.dart';
|
||||
export 'popup.dart';
|
||||
export 'route_motion_hold.dart';
|
||||
export 'scaffold.dart';
|
||||
export 'scroll.dart';
|
||||
export 'setting.dart';
|
||||
export 'sheet.dart';
|
||||
export 'sheet_header.dart';
|
||||
export 'sheet_navigator.dart';
|
||||
export 'side_sheet.dart';
|
||||
export 'sidebar.dart';
|
||||
export 'snap_sheet.dart';
|
||||
export 'skeleton.dart';
|
||||
export 'subscription_info_view.dart';
|
||||
export 'super_grid.dart';
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/status_manager.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/disclaimer.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Future<ProviderContainer> _pumpHost(
|
||||
@@ -166,6 +169,42 @@ void main() {
|
||||
expect(await result, 'picked');
|
||||
});
|
||||
|
||||
testWidgets('a dialog stacked on a dialog brings its own scrim', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
final result = dialogs.showCommonDialog<void>(
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
dialogs.showCommonDialog<void>(
|
||||
context: context,
|
||||
child: const Text('inner'),
|
||||
);
|
||||
},
|
||||
child: const Text('open inner'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final outer = tester.element(find.text('open inner'));
|
||||
final scrim = outer.colorScheme.scrim.withValues(alpha: 0.32);
|
||||
expect(ModalRoute.of(outer)!.barrierColor, scrim);
|
||||
|
||||
await tester.tap(find.text('open inner'));
|
||||
await tester.pumpAndSettle();
|
||||
final inner = tester.element(find.text('inner'));
|
||||
expect(ModalRoute.of(inner)!.barrierColor, scrim);
|
||||
expect(find.byType(BackdropFilter), findsNothing);
|
||||
|
||||
Navigator.of(inner).pop();
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(outer).pop();
|
||||
await tester.pumpAndSettle();
|
||||
await result;
|
||||
});
|
||||
|
||||
testWidgets('showAllUpdatingMessagesDialog lists every message', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -185,7 +224,7 @@ void main() {
|
||||
final messageText = tester.widget<Text>(find.text(longMessage));
|
||||
expect(messageText.maxLines, 2);
|
||||
expect(messageText.overflow, TextOverflow.ellipsis);
|
||||
expect(find.byIcon(Icons.error_outline), findsNothing);
|
||||
expect(find.byGlyph(AppGlyphs.error), findsNothing);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text(longMessage),
|
||||
@@ -199,22 +238,40 @@ void main() {
|
||||
expect(await result, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('showDisclaimer maps agree and exit to a boolean', (
|
||||
testWidgets(
|
||||
'requestDisclaimerConsent maps agree, exit, and back to a boolean',
|
||||
(tester) async {
|
||||
await _pumpHost(tester);
|
||||
|
||||
final agreed = requestDisclaimerConsent();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Data collection and privacy'), findsOneWidget);
|
||||
await tester.tap(find.text('Agree'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await agreed, isTrue);
|
||||
|
||||
final declined = requestDisclaimerConsent();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Exit'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await declined, isFalse);
|
||||
|
||||
final dismissed = requestDisclaimerConsent();
|
||||
await tester.pumpAndSettle();
|
||||
rootNavigatorKey.currentState!.pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(await dismissed, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('DisclaimerView without consent shows no agree or exit', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHost(tester);
|
||||
await _pumpHost(tester, homeBuilder: (_) => const DisclaimerView());
|
||||
|
||||
final agreed = dialogs.showDisclaimer();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Agree'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await agreed, isTrue);
|
||||
|
||||
final declined = dialogs.showDisclaimer();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Exit'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await declined, isFalse);
|
||||
expect(find.text('Disclaimer'), findsWidgets);
|
||||
expect(find.text('Agree'), findsNothing);
|
||||
expect(find.text('Exit'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('showNotifier delivers text through the StatusManager host', (
|
||||
|
||||
@@ -66,33 +66,44 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('getOverwriteLabel', () {
|
||||
group('uniqueLabelFor', () {
|
||||
String unique(String name, Set<String> taken) =>
|
||||
uniqueLabelFor(name, fallback: 'Fallback', taken: taken.contains);
|
||||
|
||||
test('keeps a free label', () {
|
||||
expect(unique('foo', {}), 'foo');
|
||||
});
|
||||
|
||||
test('appends (1) to label without number', () {
|
||||
expect(getOverwriteLabel('foo'), 'foo(1)');
|
||||
expect(unique('foo', {'foo'}), 'foo(1)');
|
||||
});
|
||||
|
||||
test('increments existing number', () {
|
||||
expect(getOverwriteLabel('foo(1)'), 'foo(2)');
|
||||
expect(unique('foo(1)', {'foo(1)'}), 'foo(2)');
|
||||
});
|
||||
|
||||
test('increments higher numbers', () {
|
||||
expect(getOverwriteLabel('foo(9)'), 'foo(10)');
|
||||
expect(unique('foo(9)', {'foo(9)'}), 'foo(10)');
|
||||
});
|
||||
|
||||
test('increments a two-digit suffix into three digits', () {
|
||||
expect(getOverwriteLabel('foo(99)'), 'foo(100)');
|
||||
expect(unique('foo(99)', {'foo(99)'}), 'foo(100)');
|
||||
});
|
||||
|
||||
test('increments an existing three-digit suffix', () {
|
||||
expect(getOverwriteLabel('foo(100)'), 'foo(101)');
|
||||
expect(unique('foo(100)', {'foo(100)'}), 'foo(101)');
|
||||
});
|
||||
|
||||
test('increments a bare parenthesized number', () {
|
||||
expect(getOverwriteLabel('(1)'), '(2)');
|
||||
expect(unique('(1)', {'(1)'}), '(2)');
|
||||
});
|
||||
|
||||
test('increments a single-character label', () {
|
||||
expect(getOverwriteLabel('a(1)'), 'a(2)');
|
||||
expect(unique('a(1)', {'a(1)'}), 'a(2)');
|
||||
});
|
||||
|
||||
test('steps past numbers already taken', () {
|
||||
expect(unique('foo', {'foo', 'foo(1)', 'foo(2)'}), 'foo(3)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +149,25 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('canExpandSidebar', () {
|
||||
test('expands only in the desktop view mode', () {
|
||||
expect(canExpandSidebar(getViewMode(maxLaptopWidth + 1)), isTrue);
|
||||
expect(canExpandSidebar(getViewMode(maxLaptopWidth.toDouble())), isFalse);
|
||||
expect(canExpandSidebar(ViewMode.mobile), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('windowControlsOverSidebar', () {
|
||||
test('only macOS 11 and later draw the traffic lights over it', () {
|
||||
expect(
|
||||
windowControlsOverSidebar(isMacOS: true, version: 15),
|
||||
macOSTrafficLightsArea,
|
||||
);
|
||||
expect(windowControlsOverSidebar(isMacOS: true, version: 10), Size.zero);
|
||||
expect(windowControlsOverSidebar(isMacOS: false, version: 11), Size.zero);
|
||||
});
|
||||
});
|
||||
|
||||
group('getProxiesColumns', () {
|
||||
test('minimum 2 columns', () {
|
||||
expect(getProxiesColumns(100, ProxiesLayout.standard), 2);
|
||||
|
||||
@@ -78,6 +78,121 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('drag back', () {
|
||||
Future<void> openPage(
|
||||
WidgetTester tester,
|
||||
double width,
|
||||
Widget page,
|
||||
) async {
|
||||
setViewWidth(width);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => BaseNavigator.push(context, page),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
const page = Scaffold(body: Center(child: Text('pushed page')));
|
||||
|
||||
for (final width in [400.0, 1400.0]) {
|
||||
testWidgets('pops after a long right drag at width $width', (
|
||||
tester,
|
||||
) async {
|
||||
await openPage(tester, width, page);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text('pushed page'),
|
||||
Offset(tester.getSize(find.byType(Scaffold).last).width * 0.7, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('pushed page'), findsNothing);
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stays after a short slow right drag at width $width', (
|
||||
tester,
|
||||
) async {
|
||||
await openPage(tester, width, page);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text('pushed page'),
|
||||
const Offset(80, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('pushed page'), findsOneWidget);
|
||||
expect(tester.getTopLeft(find.byType(Scaffold).last), Offset.zero);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('pops on a quick right fling', (tester) async {
|
||||
await openPage(tester, 400, page);
|
||||
|
||||
await tester.fling(find.text('pushed page'), const Offset(120, 0), 1500);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('pushed page'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('is disabled when the page vetoes popping', (tester) async {
|
||||
await openPage(tester, 400, const PopScope(canPop: false, child: page));
|
||||
|
||||
await tester.timedDrag(
|
||||
find.text('pushed page'),
|
||||
const Offset(300, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('pushed page'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('yields to a horizontal scrollable inside the page', (
|
||||
tester,
|
||||
) async {
|
||||
await openPage(
|
||||
tester,
|
||||
400,
|
||||
Scaffold(
|
||||
body: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (var i = 0; i < 20; i++)
|
||||
SizedBox(width: 200, child: Text('item $i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
final list = find.byType(Scrollable).last;
|
||||
tester.state<ScrollableState>(list).position.jumpTo(600);
|
||||
await tester.pump();
|
||||
|
||||
await tester.timedDrag(
|
||||
list,
|
||||
const Offset(300, 0),
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ListView), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('route configuration', () {
|
||||
test('desktop route exposes a transparent barrier and keeps state', () {
|
||||
final route = CommonDesktopRoute<void>(builder: (_) => const SizedBox());
|
||||
|
||||
@@ -23,28 +23,26 @@ Future<StatusManagerState> _pumpStatusManager(
|
||||
return tester.state<StatusManagerState>(find.byType(StatusManager));
|
||||
}
|
||||
|
||||
double _revealOf(WidgetTester tester, String text) {
|
||||
return tester
|
||||
.widget<SizeTransition>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text(text),
|
||||
matching: find.byType(SizeTransition),
|
||||
)
|
||||
.first,
|
||||
Finder _rowOf(String text) {
|
||||
return find
|
||||
.ancestor(
|
||||
of: find.text(text),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Align && widget.heightFactor != null,
|
||||
),
|
||||
)
|
||||
.sizeFactor
|
||||
.value;
|
||||
.first;
|
||||
}
|
||||
|
||||
double _revealOf(WidgetTester tester, String text) {
|
||||
return tester.widget<Align>(_rowOf(text)).heightFactor!;
|
||||
}
|
||||
|
||||
double _opacityOf(WidgetTester tester, String text) {
|
||||
final transition = find
|
||||
.ancestor(of: find.text(text), matching: find.byType(SizeTransition))
|
||||
.first;
|
||||
return tester
|
||||
.widget<FadeTransition>(
|
||||
find
|
||||
.descendant(of: transition, matching: find.byType(FadeTransition))
|
||||
.descendant(of: _rowOf(text), matching: find.byType(FadeTransition))
|
||||
.first,
|
||||
)
|
||||
.opacity
|
||||
@@ -52,15 +50,21 @@ double _opacityOf(WidgetTester tester, String text) {
|
||||
}
|
||||
|
||||
Offset _offsetOf(WidgetTester tester, String text) {
|
||||
final size = find
|
||||
.ancestor(of: find.text(text), matching: find.byType(SizeTransition))
|
||||
.first;
|
||||
return tester
|
||||
.widget<SlideTransition>(
|
||||
find.ancestor(of: size, matching: find.byType(SlideTransition)).first,
|
||||
final translation = tester
|
||||
.widget<Transform>(
|
||||
find
|
||||
.descendant(of: _rowOf(text), matching: find.byType(Transform))
|
||||
.first,
|
||||
)
|
||||
.position
|
||||
.value;
|
||||
.transform
|
||||
.getTranslation();
|
||||
return Offset(translation.x, translation.y);
|
||||
}
|
||||
|
||||
Finder _cardOf(String text) {
|
||||
return find
|
||||
.ancestor(of: find.text(text), matching: find.byType(Material))
|
||||
.first;
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -167,17 +171,16 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('a new message slides in from the trailing edge', (tester) async {
|
||||
testWidgets('a new message drops in from above', (tester) async {
|
||||
final state = await _pumpStatusManager(tester);
|
||||
state.message('animated');
|
||||
await tester.pump();
|
||||
|
||||
expect(_offsetOf(tester, 'animated').dx, greaterThan(0));
|
||||
expect(_offsetOf(tester, 'animated').dy, 0);
|
||||
expect(_offsetOf(tester, 'animated').dy, lessThan(0));
|
||||
expect(_offsetOf(tester, 'animated').dx, 0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
final mid = _offsetOf(tester, 'animated').dx;
|
||||
expect(mid, greaterThan(0));
|
||||
expect(_offsetOf(tester, 'animated').dy, lessThan(0));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(_offsetOf(tester, 'animated'), Offset.zero);
|
||||
@@ -356,7 +359,7 @@ void main() {
|
||||
state.message('swipe me');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(Dismissible), const Offset(600, 0));
|
||||
await tester.drag(_cardOf('swipe me'), const Offset(600, 0));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
@@ -373,7 +376,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Dismissible)),
|
||||
tester.getCenter(_cardOf('held while the finger stays down on the card')),
|
||||
);
|
||||
for (var step = 0; step < 4; step++) {
|
||||
await gesture.moveBy(const Offset(10, 0));
|
||||
@@ -410,10 +413,7 @@ void main() {
|
||||
state.message('the newer message that would expire first');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final held = find.ancestor(
|
||||
of: find.text('the older message sitting underneath the held one'),
|
||||
matching: find.byType(Dismissible),
|
||||
);
|
||||
final held = _cardOf('the older message sitting underneath the held one');
|
||||
final gesture = await tester.startGesture(tester.getCenter(held));
|
||||
for (var step = 0; step < 4; step++) {
|
||||
await gesture.moveBy(const Offset(10, 0));
|
||||
@@ -450,7 +450,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Dismissible)),
|
||||
tester.getCenter(_cardOf('the message being held down by a finger')),
|
||||
);
|
||||
for (var step = 0; step < 4; step++) {
|
||||
await gesture.moveBy(const Offset(10, 0));
|
||||
@@ -469,13 +469,62 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('a swiped message leaves in the direction of the swipe', (
|
||||
tester,
|
||||
) async {
|
||||
for (final direction in [-1.0, 1.0]) {
|
||||
final state = await _pumpStatusManager(tester);
|
||||
state.message('swipe me');
|
||||
await tester.pumpAndSettle();
|
||||
final restingLeft = tester.getTopLeft(find.text('swipe me')).dx;
|
||||
|
||||
await tester.drag(_cardOf('swipe me'), Offset(300 * direction, 0));
|
||||
await tester.pump();
|
||||
final releasedAt = tester.getTopLeft(find.text('swipe me')).dx;
|
||||
expect((releasedAt - restingLeft).sign, direction);
|
||||
|
||||
while (find.text('swipe me').evaluate().isNotEmpty) {
|
||||
final left = tester.getTopLeft(find.text('swipe me')).dx;
|
||||
expect((left - releasedAt) * direction, greaterThanOrEqualTo(0));
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
}
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a short drag springs back and keeps the message', (
|
||||
tester,
|
||||
) async {
|
||||
final state = await _pumpStatusManager(tester);
|
||||
state.message('swipe me');
|
||||
await tester.pumpAndSettle();
|
||||
final restingLeft = tester.getTopLeft(find.text('swipe me')).dx;
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(_cardOf('swipe me')),
|
||||
);
|
||||
for (var step = 0; step < 4; step++) {
|
||||
await gesture.moveBy(const Offset(-10, 0));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(tester.getTopLeft(find.text('swipe me')).dx, lessThan(restingLeft));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(find.text('swipe me')).dx, restingLeft);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
|
||||
testWidgets('swipe dismiss removes the message', (tester) async {
|
||||
final state = await _pumpStatusManager(tester);
|
||||
state.message('swipe me');
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
await tester.drag(find.byType(Dismissible), const Offset(600, 0));
|
||||
await tester.drag(_cardOf('swipe me'), const Offset(600, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('swipe me'), findsNothing);
|
||||
|
||||
@@ -104,7 +104,7 @@ void main() {
|
||||
expect(restored.autoRun, false);
|
||||
expect(restored.openLogs, false);
|
||||
expect(restored.closeConnections, true);
|
||||
expect(restored.isAnimateToPage, true);
|
||||
expect(restored.tabAnimation, TabAnimation.slide);
|
||||
expect(restored.autoCheckUpdate, true);
|
||||
expect(restored.sidebarExpanded, true);
|
||||
expect(restored.minimizeOnExit, true);
|
||||
@@ -178,6 +178,24 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('a legacy isAnimateToPage sets the tab animation', () {
|
||||
expect(
|
||||
AppSettingProps.fromJson({'isAnimateToPage': false}).tabAnimation,
|
||||
TabAnimation.fade,
|
||||
);
|
||||
expect(
|
||||
AppSettingProps.fromJson({'isAnimateToPage': true}).tabAnimation,
|
||||
TabAnimation.slide,
|
||||
);
|
||||
expect(
|
||||
AppSettingProps.fromJson({
|
||||
'isAnimateToPage': false,
|
||||
'tabAnimation': 'slide',
|
||||
}).tabAnimation,
|
||||
TabAnimation.slide,
|
||||
);
|
||||
});
|
||||
|
||||
test('a legacy showLabel sets whether the sidebar is expanded', () {
|
||||
expect(
|
||||
AppSettingProps.fromJson({'showLabel': false}).sidebarExpanded,
|
||||
|
||||
+484
-109
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/common/app_ports.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/app_manager.dart';
|
||||
import 'package:fl_clash/manager/theme_manager.dart';
|
||||
@@ -8,7 +9,7 @@ import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/pages/home.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/application_setting.dart';
|
||||
import 'package:fl_clash/views/config/general.dart';
|
||||
import 'package:fl_clash/views/tools.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
@@ -17,8 +18,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Finder _glyph(Glyph glyph) => find.byGlyph(glyph);
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
navigationPort = navigation;
|
||||
@@ -39,12 +43,12 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -66,16 +70,16 @@ void main() {
|
||||
|
||||
expect(globalState.navigatorKey.currentContext, isNotNull);
|
||||
expect(container.read(viewSizeProvider), const Size(1200, 800));
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -93,14 +97,14 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const _StatefulContent(
|
||||
key: GlobalObjectKey(PageLabel.dashboard),
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -130,18 +134,32 @@ void main() {
|
||||
final sidebarContainer = tester.widget<Container>(
|
||||
sidebarBackground.first,
|
||||
);
|
||||
final colorScheme = Theme.of(
|
||||
tester.element(find.byType(AppSidebarContainer)),
|
||||
).colorScheme;
|
||||
expect(sidebarContainer.color, colorScheme.surfaceContainer);
|
||||
|
||||
final sidebarEdge = tester.widget<Material>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(AppSidebarContainer),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Material && widget.shape is BorderDirectional,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
sidebarContainer.color,
|
||||
Theme.of(
|
||||
tester.element(find.byType(AppSidebarContainer)),
|
||||
).colorScheme.surfaceContainer,
|
||||
(sidebarEdge.shape! as BorderDirectional).end.color,
|
||||
colorScheme.outlineVariant,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('count: 0'));
|
||||
await tester.pump();
|
||||
expect(find.text('count: 1'), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
|
||||
for (var width = 1180.0; width >= 500; width -= 20) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
@@ -151,38 +169,107 @@ void main() {
|
||||
}
|
||||
|
||||
expect(find.text('count: 1'), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final outgoingTools = find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.byIcon(Icons.construction),
|
||||
of: find.byType(NavigationSidebar),
|
||||
matching: _glyph(AppGlyphs.tools),
|
||||
);
|
||||
await tester.tap(outgoingTools, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsNothing);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('count: 1'), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the sidebar widens in place only at desktop width', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
final sidebar = find.byType(NavigationSidebar);
|
||||
expect(tester.getSize(sidebar).width, 220);
|
||||
|
||||
await tester.tap(find.byTooltip('Collapse'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(container.read(appSettingProvider).sidebarExpanded, isFalse);
|
||||
expect(tester.getSize(sidebar).width, 48);
|
||||
|
||||
await tester.tap(find.byTooltip('Expand'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(tester.getSize(sidebar).width, 220);
|
||||
|
||||
tester.view.physicalSize = const Size(800, 800);
|
||||
container.read(viewSizeProvider.notifier).value = const Size(800, 800);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(tester.getSize(sidebar).width, 48);
|
||||
|
||||
await tester.tap(find.byTooltip('Expand'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(tester.getSize(sidebar).width, 48);
|
||||
expect(find.text('Tools'), findsNWidgets(2));
|
||||
expect(container.read(appSettingProvider).sidebarExpanded, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'list content stays valid while resizing through the breakpoint',
|
||||
(tester) async {
|
||||
@@ -197,14 +284,14 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const ToolsView(
|
||||
key: GlobalObjectKey(PageLabel.dashboard),
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -248,12 +335,12 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
glyph: AppGlyphs.proxies,
|
||||
label: PageLabel.logs,
|
||||
modes: const [
|
||||
NavigationItemMode.desktop,
|
||||
@@ -262,7 +349,7 @@ void main() {
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.link),
|
||||
glyph: AppGlyphs.connections,
|
||||
label: PageLabel.connections,
|
||||
modes: const [
|
||||
NavigationItemMode.desktop,
|
||||
@@ -271,7 +358,7 @@ void main() {
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) =>
|
||||
const ToolsView(key: GlobalObjectKey(PageLabel.tools)),
|
||||
@@ -294,7 +381,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(ToolsView), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
|
||||
for (var width = 520.0; width <= 1200; width += 20) {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
@@ -305,7 +392,7 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.byType(ToolsView), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
|
||||
for (var width = 1180.0; width >= 500; width -= 20) {
|
||||
@@ -386,27 +473,32 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final applicationItem = find.text('Application');
|
||||
final generalItem = find.text('General');
|
||||
await tester.scrollUntilVisible(
|
||||
applicationItem,
|
||||
generalItem,
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(applicationItem);
|
||||
await tester.tap(generalItem);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ApplicationSettingView), findsOneWidget);
|
||||
expect(find.byType(GeneralView), findsOneWidget);
|
||||
|
||||
final logItem = find.text('Logcat');
|
||||
await tester.scrollUntilVisible(
|
||||
logItem,
|
||||
500,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(GeneralView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.tap(logItem);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(container.read(appSettingProvider).openLogs, isTrue);
|
||||
expect(find.byType(ApplicationSettingView), findsOneWidget);
|
||||
expect(find.byType(GeneralView), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
@@ -425,7 +517,7 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
@@ -436,7 +528,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
glyph: AppGlyphs.proxies,
|
||||
label: PageLabel.proxies,
|
||||
builder: (_) => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
@@ -462,62 +554,59 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
|
||||
bool focusInRail() {
|
||||
bool focusInSidebar() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
|
||||
null;
|
||||
}
|
||||
|
||||
IconData? focusedRailIcon() {
|
||||
Glyph? focusedSidebarIcon() {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
if (!focusInRail() || focusNode == null) {
|
||||
if (!focusInSidebar() || focusNode == null) {
|
||||
return null;
|
||||
}
|
||||
return [Icons.space_dashboard, Icons.article].reduce((closest, icon) {
|
||||
final closestDistance =
|
||||
(tester.getCenter(find.byIcon(closest)).dy -
|
||||
focusNode.rect.center.dy)
|
||||
.abs();
|
||||
final distance =
|
||||
(tester.getCenter(find.byIcon(icon)).dy -
|
||||
focusNode.rect.center.dy)
|
||||
.abs();
|
||||
return distance < closestDistance ? icon : closest;
|
||||
});
|
||||
for (final glyph in [AppGlyphs.dashboard, AppGlyphs.proxies]) {
|
||||
if (focusNode.rect.contains(tester.getCenter(_glyph(glyph)))) {
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 30 && !focusInRail(); i++) {
|
||||
for (var i = 0; i < 30 && focusedSidebarIcon() == null; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(focusInRail(), isTrue);
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(focusedRailIcon(), Icons.article);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.proxies);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.proxies);
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(rail.selectedIndex, 1);
|
||||
expect(focusedRailIcon(), Icons.article);
|
||||
final sidebar = tester.widget<NavigationSidebar>(
|
||||
find.byType(NavigationSidebar),
|
||||
);
|
||||
expect(sidebar.selectedIndex, 1);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.proxies);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
expect(focusedRailIcon(), Icons.space_dashboard);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.dashboard);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(focusedRailIcon(), Icons.article);
|
||||
expect(focusedSidebarIcon(), AppGlyphs.proxies);
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
},
|
||||
);
|
||||
@@ -538,10 +627,15 @@ void main() {
|
||||
Positioned(
|
||||
left: 200,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
key: const ValueKey('content-action'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
child: Builder(
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: BottomInsetScope.of(context)),
|
||||
child: IconButton(
|
||||
key: const ValueKey('content-action'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -554,22 +648,22 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => page('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
glyph: AppGlyphs.profiles,
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) => page('profiles'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => page('tools'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.article),
|
||||
glyph: AppGlyphs.proxies,
|
||||
label: PageLabel.logs,
|
||||
builder: (_) => page('logs'),
|
||||
),
|
||||
@@ -589,12 +683,13 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
|
||||
NavigationBar navBar() =>
|
||||
tester.widget<NavigationBar>(find.byType(NavigationBar));
|
||||
FloatingNavigationBar navBar() => tester.widget<FloatingNavigationBar>(
|
||||
find.byType(FloatingNavigationBar),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.construction));
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(navBar().selectedIndex, 2);
|
||||
@@ -602,7 +697,8 @@ void main() {
|
||||
|
||||
bool focusInNav() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationBar>() != null;
|
||||
return context?.findAncestorWidgetOfExactType<FloatingNavigationBar>() !=
|
||||
null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 20 && !focusInNav(); i++) {
|
||||
@@ -619,11 +715,11 @@ void main() {
|
||||
expect(navBar().selectedIndex, 1);
|
||||
expect(find.text('page:profiles'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.construction));
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.byIcon(Icons.article));
|
||||
await tester.tap(_glyph(AppGlyphs.proxies));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.byIcon(Icons.folder));
|
||||
await tester.tap(_glyph(AppGlyphs.profiles));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
expect(navBar().selectedIndex, 1);
|
||||
@@ -642,6 +738,205 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a non-floating bottom bar leaves pages undocked', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
Widget page(String label) {
|
||||
return Builder(
|
||||
builder: (context) => Text(
|
||||
'page:$label docked:${DockedPageScope.of(context)} '
|
||||
'inset:${BottomInsetScope.of(context)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => page('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => page('tools'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(floatingNavigationBar: false));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.text('page:dashboard docked:false inset:0.0'), findsOneWidget);
|
||||
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(
|
||||
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
|
||||
1,
|
||||
);
|
||||
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(floatingNavigationBar: true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.byType(FloatingNavigationBar), findsOneWidget);
|
||||
expect(find.textContaining('page:tools docked:true'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the fade tab switch cross-fades the pages in place', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const Text('page:dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const Text('page:tools'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
|
||||
container
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(tabAnimation: TabAnimation.fade));
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final start = tester.getTopLeft(find.text('page:dashboard')).dx;
|
||||
|
||||
container.read(currentPageLabelProvider.notifier).toPage(PageLabel.tools);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
|
||||
double opacityOf(String text) => tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(of: find.text(text), matching: find.byType(Opacity)),
|
||||
)
|
||||
.opacity;
|
||||
expect(tester.getTopLeft(find.text('page:dashboard')).dx, start);
|
||||
expect(tester.getTopLeft(find.text('page:tools')).dx, start);
|
||||
expect(opacityOf('page:dashboard'), inExclusiveRange(0, 1));
|
||||
expect(
|
||||
opacityOf('page:dashboard') + opacityOf('page:tools'),
|
||||
moreOrLessEquals(1),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(tester.getTopLeft(find.text('page:tools')).dx, start);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a far mobile page switch builds no page in between', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(500, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final inits = <PageLabel>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
for (final (glyph, label) in [
|
||||
(AppGlyphs.dashboard, PageLabel.dashboard),
|
||||
(AppGlyphs.proxies, PageLabel.proxies),
|
||||
(AppGlyphs.profiles, PageLabel.profiles),
|
||||
(AppGlyphs.tools, PageLabel.tools),
|
||||
])
|
||||
NavigationItem(
|
||||
glyph: glyph,
|
||||
label: label,
|
||||
builder: (_) => _InitRecorder(label: label, inits: inits),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(500, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('page:dashboard'), findsOneWidget);
|
||||
expect(find.text('page:tools'), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('page:dashboard'), findsNothing);
|
||||
expect(find.text('page:tools'), findsOneWidget);
|
||||
|
||||
await tester.tap(_glyph(AppGlyphs.dashboard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('page:dashboard'), findsOneWidget);
|
||||
expect(inits.toSet(), {PageLabel.dashboard, PageLabel.tools});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('switching home pages exits a generic search layer', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -657,7 +952,7 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => CommonScaffold(
|
||||
title: 'Search page',
|
||||
@@ -670,7 +965,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -691,21 +986,82 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'needle');
|
||||
expect(query, 'needle');
|
||||
|
||||
await tester.tap(find.byIcon(Icons.construction));
|
||||
await tester.tap(_glyph(AppGlyphs.tools));
|
||||
await tester.pumpAndSettle();
|
||||
expect(query, isEmpty);
|
||||
await tester.tap(find.byIcon(Icons.space_dashboard));
|
||||
await tester.tap(_glyph(AppGlyphs.dashboard));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a page kept alive off screen stops asking for frames', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.profiles,
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1200, 800);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
Finder railIcon(Glyph glyph) => find.descendant(
|
||||
of: find.byType(NavigationSidebar),
|
||||
matching: _glyph(glyph),
|
||||
);
|
||||
|
||||
await tester.tap(railIcon(AppGlyphs.profiles));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||
|
||||
await tester.tap(railIcon(AppGlyphs.dashboard));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byType(CircularProgressIndicator, skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('desktop nested route inherits home page activity', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -721,7 +1077,7 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => _NestedSearchLauncher(
|
||||
onSearch: (value) {
|
||||
@@ -730,7 +1086,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -753,17 +1109,14 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('Open nested search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'needle');
|
||||
expect(query, 'needle');
|
||||
|
||||
final navigationRail = find.byType(NavigationRail);
|
||||
final navigationRail = find.byType(NavigationSidebar);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: navigationRail,
|
||||
matching: find.byIcon(Icons.construction),
|
||||
),
|
||||
find.descendant(of: navigationRail, matching: _glyph(AppGlyphs.tools)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(query, isEmpty);
|
||||
@@ -771,7 +1124,7 @@ void main() {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: navigationRail,
|
||||
matching: find.byIcon(Icons.space_dashboard),
|
||||
matching: _glyph(AppGlyphs.dashboard),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -807,12 +1160,12 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => pageContent('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.folder),
|
||||
glyph: AppGlyphs.profiles,
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) => pageContent('profiles'),
|
||||
),
|
||||
@@ -832,27 +1185,28 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
|
||||
Finder railIcon(IconData icon) => find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.byIcon(icon),
|
||||
Finder railIcon(Glyph glyph) => find.descendant(
|
||||
of: find.byType(NavigationSidebar),
|
||||
matching: _glyph(glyph),
|
||||
);
|
||||
|
||||
// Visit another page so its content stays alive in the PageView cache.
|
||||
await tester.tap(railIcon(Icons.folder));
|
||||
await tester.tap(railIcon(AppGlyphs.profiles));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
await tester.tap(railIcon(Icons.space_dashboard));
|
||||
await tester.tap(railIcon(AppGlyphs.dashboard));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
|
||||
bool focusInRail() {
|
||||
bool focusInSidebar() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationRail>() != null;
|
||||
return context?.findAncestorWidgetOfExactType<NavigationSidebar>() !=
|
||||
null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < 40 && !focusInRail(); i++) {
|
||||
for (var i = 0; i < 40 && !focusInSidebar(); i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(
|
||||
@@ -861,7 +1215,7 @@ void main() {
|
||||
reason: 'tab $i flipped the page',
|
||||
);
|
||||
}
|
||||
expect(focusInRail(), isTrue);
|
||||
expect(focusInSidebar(), isTrue);
|
||||
expect(
|
||||
find.text('page:profiles').hitTestable(),
|
||||
findsNothing,
|
||||
@@ -951,3 +1305,24 @@ class _HomeTestProfiles extends Profiles {
|
||||
@override
|
||||
List<Profile> build() => initial;
|
||||
}
|
||||
|
||||
class _InitRecorder extends StatefulWidget {
|
||||
const _InitRecorder({required this.label, required this.inits});
|
||||
|
||||
final PageLabel label;
|
||||
final List<PageLabel> inits;
|
||||
|
||||
@override
|
||||
State<_InitRecorder> createState() => _InitRecorderState();
|
||||
}
|
||||
|
||||
class _InitRecorderState extends State<_InitRecorder> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.inits.add(widget.label);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text('page:${widget.label.name}');
|
||||
}
|
||||
|
||||
@@ -302,6 +302,40 @@ void main() {
|
||||
expect(exitingPosition.dy, lessThan(600));
|
||||
});
|
||||
|
||||
testWidgets('settled content paints past its bounds', (tester) async {
|
||||
Widget buildApp(bool visible) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
AnimatedVisibility.bottomNavigation(
|
||||
visible: visible,
|
||||
child: const SizedBox(width: 180, height: 80),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Clip clip() => tester
|
||||
.widget<ClipRect>(
|
||||
find.descendant(
|
||||
of: find.byType(AnimatedVisibility),
|
||||
matching: find.byType(ClipRect),
|
||||
),
|
||||
)
|
||||
.clipBehavior;
|
||||
|
||||
await tester.pumpWidget(buildApp(true));
|
||||
expect(clip(), Clip.none);
|
||||
|
||||
await tester.pumpWidget(buildApp(false));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(clip(), Clip.hardEdge);
|
||||
});
|
||||
|
||||
testWidgets('horizontal transition clips without narrowing its child', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void _noop() {}
|
||||
|
||||
const _mobile = Size(400, 900);
|
||||
const _desktop = Size(1200, 900);
|
||||
|
||||
final _views = {'a mobile view': _mobile, 'a desktop view': _desktop};
|
||||
|
||||
Finder _container(Finder button) =>
|
||||
find.descendant(of: button, matching: find.byType(Material)).first;
|
||||
|
||||
Iterable<Rect> _clipsAbove(RenderObject box) sync* {
|
||||
var child = box;
|
||||
for (var parent = box.parent; parent != null; parent = parent.parent) {
|
||||
final clip = parent.describeApproximatePaintClip(child);
|
||||
if (clip != null) {
|
||||
yield MatrixUtils.transformRect(parent.getTransformTo(null), clip);
|
||||
}
|
||||
child = parent;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Rect> _pumpBar(
|
||||
WidgetTester tester, {
|
||||
required Widget child,
|
||||
required Size view,
|
||||
required TargetPlatform platform,
|
||||
}) async {
|
||||
tester.view.physicalSize = view;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => view)],
|
||||
homeBuilder: (page) => Navigator(
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
MaterialPageRoute<void>(builder: (_) => const SizedBox()),
|
||||
MaterialPageRoute<void>(builder: (_) => page),
|
||||
],
|
||||
),
|
||||
child: Theme(
|
||||
data: ThemeData(platform: platform),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getRect(find.byType(NavigationToolbar).first);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final view in _views.entries) {
|
||||
final inset = appBarActionInset(view.value == _mobile);
|
||||
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.macOS]) {
|
||||
testWidgets(
|
||||
'an icon action mirrors the leading button in ${view.key} on $platform',
|
||||
(tester) async {
|
||||
final bar = await _pumpBar(
|
||||
tester,
|
||||
view: view.value,
|
||||
platform: platform,
|
||||
child: const CommonScaffold(
|
||||
title: 'Title',
|
||||
actions: [
|
||||
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
|
||||
],
|
||||
body: SizedBox(),
|
||||
),
|
||||
);
|
||||
final leading = tester.getRect(_container(find.byType(BackButton)));
|
||||
final action = tester.getRect(
|
||||
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
|
||||
);
|
||||
|
||||
expect(leading.left, inset);
|
||||
expect(bar.right - action.right, inset);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'a sheet page ends where its back button starts in ${view.key} '
|
||||
'on $platform',
|
||||
(tester) async {
|
||||
final bar = await _pumpBar(
|
||||
tester,
|
||||
view: view.value,
|
||||
platform: platform,
|
||||
child: const SheetProvider(
|
||||
type: SheetType.page,
|
||||
child: CommonScaffold(
|
||||
title: 'Providers',
|
||||
actions: [
|
||||
AppBarActionButton(
|
||||
data: IconButtonData(
|
||||
glyph: AppGlyphs.sync,
|
||||
onPressed: _noop,
|
||||
),
|
||||
),
|
||||
],
|
||||
body: SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final leading = tester.getRect(_container(find.byType(BackButton)));
|
||||
final action = tester.getRect(
|
||||
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
|
||||
);
|
||||
|
||||
expect(leading.left, inset);
|
||||
expect(bar.right - action.right, inset);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'a text action is a pill as tall as the leading button in ${view.key}',
|
||||
(tester) async {
|
||||
final bar = await _pumpBar(
|
||||
tester,
|
||||
view: view.value,
|
||||
platform: TargetPlatform.android,
|
||||
child: const CommonScaffold(
|
||||
title: 'Title',
|
||||
actions: [FilledButton.tonal(onPressed: _noop, child: Text('Add'))],
|
||||
body: SizedBox(),
|
||||
),
|
||||
);
|
||||
final leading = tester.getRect(_container(find.byType(BackButton)));
|
||||
final action = tester.getRect(find.byType(FilledButton));
|
||||
|
||||
expect(leading.width, leading.height);
|
||||
expect(action.height, leading.height);
|
||||
expect(action.width, greaterThan(action.height));
|
||||
expect(bar.right - action.right, inset);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('two bar buttons share one pressable group', (tester) async {
|
||||
final bar = await _pumpBar(
|
||||
tester,
|
||||
view: _desktop,
|
||||
platform: TargetPlatform.android,
|
||||
child: const CommonScaffold(
|
||||
title: 'Title',
|
||||
iconActions: [
|
||||
IconButtonData(glyph: AppGlyphs.sync, onPressed: _noop),
|
||||
IconButtonData(glyph: AppGlyphs.sort, onPressed: _noop),
|
||||
],
|
||||
body: SizedBox(),
|
||||
),
|
||||
);
|
||||
final group = find.byType(TonalButtonGroup);
|
||||
final leading = tester.getRect(_container(find.byType(BackButton)));
|
||||
|
||||
expect(group, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: group, matching: find.byType(IconButton)),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: group, matching: find.byType(ElasticPress)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.getSize(group).height, leading.height);
|
||||
expect(bar.right - tester.getRect(group).right, appBarActionInset(false));
|
||||
});
|
||||
|
||||
testWidgets('raw action widgets press on their own, one gap apart', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBar(
|
||||
tester,
|
||||
view: _desktop,
|
||||
platform: TargetPlatform.android,
|
||||
child: const CommonScaffold(
|
||||
title: 'Title',
|
||||
actions: [
|
||||
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
|
||||
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sort)),
|
||||
],
|
||||
body: SizedBox(),
|
||||
),
|
||||
);
|
||||
final first = find.widgetWithGlyph(IconButton, AppGlyphs.sync);
|
||||
final second = find.widgetWithGlyph(IconButton, AppGlyphs.sort);
|
||||
|
||||
expect(find.byType(TonalButtonGroup), findsNothing);
|
||||
for (final button in [first, second, find.byType(BackButton)]) {
|
||||
expect(
|
||||
find.ancestor(of: button, matching: find.byType(ElasticPress)),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
tester.getRect(second).left - tester.getRect(first).right,
|
||||
AppBarActionEdge.container.gap,
|
||||
);
|
||||
});
|
||||
|
||||
for (final type in [SheetType.page, SheetType.bottomSheet]) {
|
||||
testWidgets('a press swells past the bar of a ${type.name} unclipped', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.padding = const FakeViewPadding(top: 24);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
await _pumpBar(
|
||||
tester,
|
||||
view: _mobile,
|
||||
platform: TargetPlatform.android,
|
||||
child: SheetProvider(
|
||||
type: type,
|
||||
child: const CommonScaffold(
|
||||
title: 'Title',
|
||||
actions: [
|
||||
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
|
||||
],
|
||||
body: SizedBox(width: double.infinity, height: 200),
|
||||
),
|
||||
),
|
||||
);
|
||||
final presses = find.byType(ElasticPress).evaluate();
|
||||
|
||||
expect(presses, isNotEmpty);
|
||||
for (final press in presses) {
|
||||
final box = press.renderObject! as RenderBox;
|
||||
final rest = box.localToGlobal(Offset.zero) & box.size;
|
||||
// A press swells an eighth of the button and pulls 7/32 further.
|
||||
final swell = rest.inflate(rest.shortestSide * 11 / 32);
|
||||
for (final clip in _clipsAbove(box)) {
|
||||
expect(clip.expandToInclude(swell), clip);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (final (name, view, type, compact) in [
|
||||
('a phone page', _mobile, null, true),
|
||||
('a desktop page', _desktop, null, false),
|
||||
('a desktop side sheet', _desktop, SheetType.sideSheet, true),
|
||||
('a phone bottom sheet', _mobile, SheetType.bottomSheet, true),
|
||||
]) {
|
||||
testWidgets('$name keeps to its width class', (tester) async {
|
||||
const scaffold = CommonScaffold(
|
||||
title: 'Title',
|
||||
actions: [
|
||||
IconButton(onPressed: _noop, icon: GlyphIcon(AppGlyphs.sync)),
|
||||
],
|
||||
body: SizedBox(width: double.infinity, height: 200),
|
||||
);
|
||||
final bar = await _pumpBar(
|
||||
tester,
|
||||
view: view,
|
||||
platform: TargetPlatform.android,
|
||||
child: type == null
|
||||
? scaffold
|
||||
: SheetProvider(type: type, child: scaffold),
|
||||
);
|
||||
final title = tester.getRect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('Title')),
|
||||
);
|
||||
final action = tester.getRect(
|
||||
_container(find.widgetWithGlyph(IconButton, AppGlyphs.sync)),
|
||||
);
|
||||
|
||||
if (type == SheetType.bottomSheet) {
|
||||
expect(title.center.dx, moreOrLessEquals(bar.center.dx));
|
||||
} else {
|
||||
expect(title.center.dx, lessThan(bar.center.dx));
|
||||
}
|
||||
expect(bar.right - action.right, appBarActionInset(compact));
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('searching keeps the bar buttons where they stood', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBar(
|
||||
tester,
|
||||
view: _mobile,
|
||||
platform: TargetPlatform.android,
|
||||
child: CommonScaffold(
|
||||
title: 'Title',
|
||||
searchState: AppBarSearchState(onSearch: (_) {}),
|
||||
body: const SizedBox(),
|
||||
),
|
||||
);
|
||||
final leading = find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byType(IconButton),
|
||||
);
|
||||
Color? glyphColor(Finder button) => IconTheme.of(
|
||||
tester.element(
|
||||
find
|
||||
.descendant(
|
||||
of: button,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Icon || widget is GlyphIcon,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
),
|
||||
).color;
|
||||
final back = tester.getRect(_container(leading.first));
|
||||
final backColor = glyphColor(leading.first);
|
||||
final search = find.widgetWithGlyph(IconButton, AppGlyphs.search);
|
||||
final trailing = tester.getRect(_container(search));
|
||||
|
||||
await tester.tap(search);
|
||||
await tester.pumpAndSettle();
|
||||
final clear = find.widgetWithGlyph(IconButton, AppGlyphs.close);
|
||||
|
||||
expect(tester.getRect(_container(leading.first)), back);
|
||||
expect(glyphColor(leading.first), backColor);
|
||||
expect(tester.getRect(_container(clear)).right, trailing.right);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:fl_clash/widgets/float_layout.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -36,6 +35,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
return TestApp(
|
||||
wrapInProviderScope: true,
|
||||
includeNavigatorKey: false,
|
||||
child: inset == null
|
||||
? scaffold
|
||||
@@ -60,6 +60,7 @@ void main() {
|
||||
) async {
|
||||
Widget buildWith({double inset = 0, bool withFab = false}) {
|
||||
return TestApp(
|
||||
wrapInProviderScope: true,
|
||||
includeNavigatorKey: false,
|
||||
child: BottomInsetScope(
|
||||
inset: inset,
|
||||
@@ -101,6 +102,7 @@ void main() {
|
||||
) async {
|
||||
Widget buildWith({double inset = 0, bool withFab = false}) {
|
||||
return TestApp(
|
||||
wrapInProviderScope: true,
|
||||
includeNavigatorKey: false,
|
||||
child: BottomInsetScope(
|
||||
inset: inset,
|
||||
@@ -132,6 +134,7 @@ void main() {
|
||||
testWidgets('does not reserve FAB space on TV', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
includeNavigatorKey: false,
|
||||
child: BottomInsetScope(
|
||||
inset: navigationInset,
|
||||
@@ -151,35 +154,4 @@ void main() {
|
||||
expect(contentInset, navigationInset);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('float layout stacks its own inset on the inherited one', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
includeNavigatorKey: false,
|
||||
child: BottomInsetScope(
|
||||
inset: navigationInset,
|
||||
child: Scaffold(
|
||||
body: FloatLayout(
|
||||
isTV: false,
|
||||
floatingWidget: FloatWrapper(
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {},
|
||||
child: const Icon(Icons.save),
|
||||
),
|
||||
),
|
||||
child: contentProbe(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(contentInset, navigationInset + fabInset);
|
||||
final stackBottom = tester.getRect(find.byType(FloatLayout)).bottom;
|
||||
final fabBottom = tester.getRect(find.byType(FloatWrapper)).bottom;
|
||||
expect(stackBottom - fabBottom, navigationInset);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'package:fl_clash/common/navigator.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/open_container.dart';
|
||||
import 'package:fl_clash/widgets/paged_sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/side_sheet.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
Future<void> _dragRight(WidgetTester tester, Finder finder, double dx) {
|
||||
return tester.timedDrag(finder, Offset(dx, 0), const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
Future<void> _pumpOpener(
|
||||
WidgetTester tester,
|
||||
void Function(BuildContext context) open,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => open(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('controls that take a horizontal drag', () {
|
||||
var sliderValue = 0.5;
|
||||
|
||||
Widget page() {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const SizedBox(height: 200, child: Center(child: Text('blank'))),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (var i = 0; i < 20; i++)
|
||||
SizedBox(width: 120, child: Text('chip $i')),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => Slider(
|
||||
value: sliderValue,
|
||||
onChanged: (value) => setState(() => sliderValue = value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openPage(WidgetTester tester) {
|
||||
sliderValue = 0.5;
|
||||
return _pumpOpener(
|
||||
tester,
|
||||
(context) => Navigator.of(
|
||||
context,
|
||||
).push(CommonRoute<void>(builder: (_) => page())),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('absorb the drag, even at the scroll start', (tester) async {
|
||||
await openPage(tester);
|
||||
|
||||
await _dragRight(tester, find.text('chip 0'), 500);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('blank'), findsOneWidget);
|
||||
|
||||
await _dragRight(tester, find.byType(Slider), 300);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('blank'), findsOneWidget);
|
||||
expect(sliderValue, greaterThan(0.5));
|
||||
});
|
||||
|
||||
testWidgets('leave the blank area free to drag back', (tester) async {
|
||||
await openPage(tester);
|
||||
|
||||
await _dragRight(tester, find.text('blank'), 600);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('blank'), findsNothing);
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a slow mouse drag across a text field selects instead of dragging back',
|
||||
(tester) async {
|
||||
final controller = TextEditingController(text: 'select these words');
|
||||
addTearDown(controller.dispose);
|
||||
await _pumpOpener(
|
||||
tester,
|
||||
(context) => Navigator.of(context).push(
|
||||
CommonDesktopRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text('blank')),
|
||||
),
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: TextField(controller: controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(EditableText)),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
for (var i = 0; i < 40; i++) {
|
||||
await gesture.moveBy(const Offset(1.5, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.selection.isCollapsed, isFalse);
|
||||
|
||||
await tester.dragFrom(
|
||||
tester.getCenter(find.text('blank')),
|
||||
const Offset(600, 0),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('blank'), findsNothing);
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
},
|
||||
variant: TargetPlatformVariant.desktop(),
|
||||
);
|
||||
|
||||
testWidgets('an open container drags back to its closed tile', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 160,
|
||||
height: 80,
|
||||
child: OpenContainer<void>(
|
||||
closedBuilder: (_, open) =>
|
||||
TextButton(onPressed: open, child: const Text('tile')),
|
||||
openBuilder: (_, _) =>
|
||||
const Scaffold(body: Center(child: Text('opened'))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('tile'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _dragRight(tester, find.text('opened'), 600);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('opened'), findsNothing);
|
||||
expect(find.text('tile'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.text('tile'));
|
||||
await tester.pumpAndSettle();
|
||||
await _dragRight(tester, find.text('opened'), 600);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 30));
|
||||
expect(find.text('tile'), findsOneWidget);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('opened'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a modal side sheet drags off to close', (tester) async {
|
||||
await _pumpOpener(
|
||||
tester,
|
||||
(context) => showModalSideSheet<void>(
|
||||
context: context,
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
builder: (_) =>
|
||||
const SizedBox.expand(child: Center(child: Text('side'))),
|
||||
),
|
||||
);
|
||||
|
||||
await _dragRight(tester, find.text('side'), 60);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('side'), findsOneWidget);
|
||||
|
||||
await _dragRight(tester, find.text('side'), 220);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('side'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a paged sheet drags back one page and keeps its first', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PagedSheet(
|
||||
child: Navigator(
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
PagedSheetRoute<void>(
|
||||
builder: (context) => SizedBox(
|
||||
height: 300,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
PagedSheetRoute<void>(
|
||||
builder: (_) => const SizedBox(
|
||||
height: 400,
|
||||
child: Center(child: Text('second')),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('first'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('first'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _dragRight(tester, find.text('second'), 600);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('second'), findsNothing);
|
||||
expect(find.text('first'), findsOneWidget);
|
||||
|
||||
await _dragRight(tester, find.text('first'), 600);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform {
|
||||
final String root;
|
||||
@@ -35,6 +36,21 @@ class _NoopSetupAction extends SetupAction {
|
||||
void autoApplyProfile() {}
|
||||
}
|
||||
|
||||
class _RenameConflictProfilesAction extends ProfilesAction {
|
||||
final put = <Profile>[];
|
||||
|
||||
@override
|
||||
Future<({List<Profile> renameIn, List<Profile> conflicts})> providerRename(
|
||||
Profile previous,
|
||||
Profile next,
|
||||
) async =>
|
||||
(renameIn: const <Profile>[], conflicts: [Profile.normal(label: 'Work')]);
|
||||
|
||||
@override
|
||||
void putProfile(Profile profile, {Iterable<int> renameIn = const []}) =>
|
||||
put.add(profile);
|
||||
}
|
||||
|
||||
Profile _urlProfile() =>
|
||||
Profile.normal(label: 'test', url: 'https://example.com/sub');
|
||||
|
||||
@@ -43,6 +59,7 @@ Profile _urlProfile() =>
|
||||
Future<FocusNode> pumpEditProfile(
|
||||
WidgetTester tester, {
|
||||
Profile? profile,
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(900, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -50,7 +67,10 @@ Future<FocusNode> pumpEditProfile(
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [setupActionProvider.overrideWith(() => _NoopSetupAction())],
|
||||
overrides: [
|
||||
setupActionProvider.overrideWith(() => _NoopSetupAction()),
|
||||
...overrides,
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
// Unmount the widget tree before the container is disposed so State.dispose
|
||||
@@ -60,6 +80,7 @@ Future<FocusNode> pumpEditProfile(
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(900, 800);
|
||||
|
||||
final outsideFocus = FocusNode();
|
||||
addTearDown(outsideFocus.dispose);
|
||||
@@ -68,6 +89,7 @@ Future<FocusNode> pumpEditProfile(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp(
|
||||
navigatorKey: globalState.navigatorKey,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
@@ -113,9 +135,9 @@ Future<FocusNode> pumpEditProfile(
|
||||
return outsideFocus;
|
||||
}
|
||||
|
||||
bool _isFabFocused() {
|
||||
bool _isInAppBar() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
return context?.findAncestorWidgetOfExactType<FloatingActionButton>() != null;
|
||||
return context?.findAncestorWidgetOfExactType<AppBar>() != null;
|
||||
}
|
||||
|
||||
bool _isTextFieldFocused() {
|
||||
@@ -137,16 +159,44 @@ void main() {
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
testWidgets('tabbing into the edit page starts with the form', (
|
||||
testWidgets('tabbing into the edit page reaches the form past its bar', (
|
||||
tester,
|
||||
) async {
|
||||
final outsideFocus = await pumpEditProfile(tester);
|
||||
expect(FocusManager.instance.primaryFocus, outsideFocus);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < 4 && !_isTextFieldFocused(); i++) {
|
||||
if (i > 0) {
|
||||
expect(_isInAppBar(), isTrue);
|
||||
}
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(_isTextFieldFocused(), isTrue);
|
||||
expect(_isFabFocused(), isFalse);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
expect(find.byTooltip('Save'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a name a user subscription already has is not saved', (
|
||||
tester,
|
||||
) async {
|
||||
final action = _RenameConflictProfilesAction();
|
||||
await pumpEditProfile(
|
||||
tester,
|
||||
overrides: [profilesActionProvider.overrideWith(() => action)],
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, currentAppLocalizations.name),
|
||||
'Taken',
|
||||
);
|
||||
await tester.tap(find.byTooltip(currentAppLocalizations.save));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('subscriptions of Work already have Taken'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(action.put, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -17,7 +20,7 @@ Widget _buildPage({bool wrapNavigator = false}) {
|
||||
),
|
||||
floatingActionButton: CommonFloatingActionButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const GlyphIcon(AppGlyphs.add),
|
||||
label: 'add',
|
||||
),
|
||||
);
|
||||
@@ -139,6 +142,100 @@ void main() {
|
||||
expect(_focusedItemKey(), 'item1');
|
||||
});
|
||||
|
||||
testWidgets('down stays on the last control when nothing lies below', (
|
||||
tester,
|
||||
) async {
|
||||
final outsideFocus = await _pumpWithOutsideFocus(tester, _buildPage());
|
||||
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(_isFabFocused(), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(_isFabFocused(), isTrue);
|
||||
expect(FocusManager.instance.primaryFocus, isNot(outsideFocus));
|
||||
});
|
||||
|
||||
testWidgets('left picks the sidebar item beside the focused row', (
|
||||
tester,
|
||||
) async {
|
||||
final sidebarFocus = List.generate(3, (_) => FocusNode());
|
||||
for (final node in sidebarFocus) {
|
||||
addTearDown(node.dispose);
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
for (final node in sidebarFocus)
|
||||
Focus(
|
||||
focusNode: node,
|
||||
child: const SizedBox(width: 80, height: 48),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: _buildPage(wrapNavigator: true)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
sidebarFocus.first.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(_focusedItemKey(), 'item2');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus, sidebarFocus[2]);
|
||||
});
|
||||
|
||||
testWidgets('down from the last control reaches a bar below the page', (
|
||||
tester,
|
||||
) async {
|
||||
final barFocus = FocusNode();
|
||||
addTearDown(barFocus.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Column(
|
||||
children: [
|
||||
Expanded(child: _buildPage()),
|
||||
Focus(
|
||||
focusNode: barFocus,
|
||||
child: const SizedBox(height: 80, width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
barFocus.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(_isFabFocused(), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus, barFocus);
|
||||
});
|
||||
|
||||
testWidgets('left leaves page content for an adjacent sidebar', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -170,4 +267,39 @@ void main() {
|
||||
|
||||
expect(FocusManager.instance.primaryFocus, sidebarFocus);
|
||||
});
|
||||
|
||||
testWidgets('left stays in a pushed page instead of the page it covers', (
|
||||
tester,
|
||||
) async {
|
||||
final coveredFocus = FocusNode();
|
||||
addTearDown(coveredFocus.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Row(
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: coveredFocus,
|
||||
child: const SizedBox(width: 80, height: 600),
|
||||
),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
tester
|
||||
.state<NavigatorState>(find.byType(Navigator))
|
||||
.push(MaterialPageRoute<void>(builder: (_) => _buildPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(_focusedItemKey(), 'item0');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
|
||||
expect(_focusedItemKey(), 'item0');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/paged_sheet.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet_header.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
const _view = Size(400, 900);
|
||||
const _rowHeight = 48.0;
|
||||
|
||||
Widget _list(int rows) {
|
||||
return CommonScaffold(
|
||||
title: 'title',
|
||||
body: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (var index = 0; index < rows; index++)
|
||||
ListTile(
|
||||
title: Text('row $index'),
|
||||
minTileHeight: _rowHeight,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
Future<Future<Object?>> openSheet(
|
||||
WidgetTester tester,
|
||||
WidgetBuilder builder, {
|
||||
SheetProps props = const SheetProps(),
|
||||
}) async {
|
||||
tester.view.physicalSize = _view;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
late Future<Object?> result;
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => _view)],
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
result = showSheet<Object?>(
|
||||
context: context,
|
||||
props: props,
|
||||
builder: builder,
|
||||
);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
return result;
|
||||
}
|
||||
|
||||
double sheetTop(WidgetTester tester) =>
|
||||
tester.getTopLeft(find.byType(SheetDragHandle)).dy;
|
||||
|
||||
Finder content() => find.byType(Scrollable).last;
|
||||
|
||||
double contentOffset(WidgetTester tester) =>
|
||||
tester.state<ScrollableState>(content()).position.pixels;
|
||||
|
||||
bool isOpen(WidgetTester tester) => tester.any(find.text('title'));
|
||||
|
||||
testWidgets('a short sheet rests at its content height and closes when '
|
||||
'its content is dragged down', (tester) async {
|
||||
final result = await openSheet(tester, (_) => _list(3));
|
||||
var closed = false;
|
||||
unawaited(result.then((_) => closed = true));
|
||||
|
||||
final top = sheetTop(tester);
|
||||
expect(top, greaterThan(_view.height / 2));
|
||||
expect(tester.getBottomLeft(find.text('row 2')).dy, lessThan(_view.height));
|
||||
|
||||
await tester.drag(find.text('row 1'), const Offset(0, 40));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheetTop(tester), closeTo(top, 1));
|
||||
expect(isOpen(tester), isTrue);
|
||||
|
||||
await tester.drag(find.text('row 1'), Offset(0, _view.height - top));
|
||||
await tester.pumpAndSettle();
|
||||
expect(closed, isTrue);
|
||||
expect(isOpen(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a long sheet stops at the short cap, scrolls its content '
|
||||
'first and closes from the top', (tester) async {
|
||||
await openSheet(tester, (_) => _list(60));
|
||||
final top = sheetTop(tester);
|
||||
expect(top, closeTo(_view.height * 7 / 16, 60));
|
||||
|
||||
await tester.drag(content(), const Offset(0, -200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(contentOffset(tester), greaterThan(0));
|
||||
expect(sheetTop(tester), closeTo(top, 1));
|
||||
|
||||
await tester.drag(content(), const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(contentOffset(tester), 0);
|
||||
expect(isOpen(tester), isTrue);
|
||||
|
||||
await tester.fling(content(), const Offset(0, 300), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(isOpen(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a sheet that controls its scrolling may grow past the cap', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) => _list(60),
|
||||
props: const SheetProps(isScrollControlled: true),
|
||||
);
|
||||
expect(sheetTop(tester), lessThan(_view.height * 7 / 16 - 60));
|
||||
});
|
||||
|
||||
testWidgets('a tap outside closes the sheet', (tester) async {
|
||||
await openSheet(tester, (_) => _list(3));
|
||||
|
||||
await tester.tapAt(const Offset(200, 40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(isOpen(tester), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a nested sheet decides what a drag away and a tap outside do', (
|
||||
tester,
|
||||
) async {
|
||||
final dismissals = <bool>[];
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) =>
|
||||
NestedPagedSheet(builder: (_) => _list(3), onDismiss: dismissals.add),
|
||||
props: nestedPagedSheetProps,
|
||||
);
|
||||
final top = sheetTop(tester);
|
||||
|
||||
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(200, 40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(dismissals, [false, false]);
|
||||
expect(isOpen(tester), isTrue);
|
||||
expect(sheetTop(tester), closeTo(top, 1));
|
||||
});
|
||||
|
||||
for (final closes in [true, false]) {
|
||||
testWidgets('a nested sheet that ${closes ? 'closes' : 'stays'} after a '
|
||||
'drag away springs back while it decides', (tester) async {
|
||||
final decision = Completer<void>();
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) => NestedPagedSheet(
|
||||
builder: (_) => _list(3),
|
||||
onDismiss: (_) async {
|
||||
await decision.future;
|
||||
if (closes) {
|
||||
globalState.navigatorKey.currentState!.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
props: nestedPagedSheetProps,
|
||||
);
|
||||
final top = sheetTop(tester);
|
||||
|
||||
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
|
||||
await tester.pump();
|
||||
expect(sheetTop(tester), greaterThan(top));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(isOpen(tester), isTrue);
|
||||
expect(sheetTop(tester), closeTo(top, 1));
|
||||
|
||||
decision.complete();
|
||||
var lowest = top;
|
||||
for (var frame = 0; frame < 30 && isOpen(tester); frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
if (!isOpen(tester)) {
|
||||
break;
|
||||
}
|
||||
final current = sheetTop(tester);
|
||||
expect(current, greaterThanOrEqualTo(lowest - 1));
|
||||
lowest = current;
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(isOpen(tester), !closes);
|
||||
if (!closes) {
|
||||
expect(sheetTop(tester), closeTo(top, 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'pages kept alive in a nested sheet scroll on their own controllers and '
|
||||
'still hand a drag down to the sheet',
|
||||
(tester) async {
|
||||
final dismissals = <bool>[];
|
||||
Widget page(String name, {VoidCallback? onNext}) => CommonScaffold(
|
||||
title: 'title',
|
||||
body: ListView(
|
||||
children: [
|
||||
if (onNext != null)
|
||||
TextButton(onPressed: onNext, child: const Text('next')),
|
||||
for (var index = 0; index < 60; index++)
|
||||
SizedBox(height: _rowHeight, child: Text('$name $index')),
|
||||
],
|
||||
),
|
||||
);
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) => NestedPagedSheet(
|
||||
builder: (context) => page(
|
||||
'a',
|
||||
onNext: () => Navigator.of(
|
||||
context,
|
||||
).push(PagedSheetRoute(builder: (_) => page('b'))),
|
||||
),
|
||||
onDismiss: dismissals.add,
|
||||
),
|
||||
props: nestedPagedSheetProps,
|
||||
);
|
||||
await tester.tap(find.text('next'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.text('b 5'), const Offset(0, -200));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.text('b 8'), const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.fling(find.text('b 3'), const Offset(0, 300), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(dismissals, [true]);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:fl_clash/widgets/scroll.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('the hint follows the thumb of a list in reading order', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FloatingScrollbar(
|
||||
controller: controller,
|
||||
hintBuilder: (fraction) => fraction.toStringAsFixed(1),
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: 200,
|
||||
itemBuilder: (_, index) =>
|
||||
SizedBox(height: 40, child: Text('$index')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Scrollable)),
|
||||
);
|
||||
for (var i = 0; i < 25; i++) {
|
||||
await gesture.moveBy(const Offset(0, -2000));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
final listRect = tester.getRect(find.byType(Scrollable));
|
||||
final hint = find.byKey(const ValueKey('scrollbarHintPill'));
|
||||
expect(hint, findsOneWidget);
|
||||
expect(tester.getCenter(hint).dy, greaterThan(listRect.center.dy));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/theme.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void _noop() {}
|
||||
|
||||
const _width = 520.0;
|
||||
|
||||
List<Widget> _actions() => const [
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton.filledTonal(
|
||||
onPressed: _noop,
|
||||
icon: GlyphIcon(AppGlyphs.delete, fill: 1),
|
||||
),
|
||||
),
|
||||
CommonMinFilledButtonTheme(
|
||||
child: FilledButton(onPressed: _noop, child: Text('Select all')),
|
||||
),
|
||||
];
|
||||
|
||||
Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
tester.view.physicalSize = const Size(_width, 200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
globalState.measure = Measure.of(context, 1);
|
||||
return Material(child: header);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getRect(find.byType(FilledButton));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('both headers end their actions at the same inset', (
|
||||
tester,
|
||||
) async {
|
||||
final list = await _pumpHeader(
|
||||
tester,
|
||||
ListHeader(title: 'Exclude SSIDs', actions: _actions()),
|
||||
);
|
||||
final info = await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Added rules'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_width - list.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a card header ends where a plain one does', (tester) async {
|
||||
final info = await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final card = await _pumpHeader(
|
||||
tester,
|
||||
ItemCard(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
space: 8,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_width - card.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a header spaces its actions like an app bar', (tester) async {
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Added rules'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final icon = tester.getRect(
|
||||
find.widgetWithGlyph(IconButton, AppGlyphs.delete),
|
||||
);
|
||||
final pill = tester.getRect(find.byType(FilledButton));
|
||||
|
||||
expect(pill.left - icon.right, appBarActionSpace);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class _InsetProbe extends StatelessWidget {
|
||||
const _InsetProbe(this.builds);
|
||||
|
||||
final List<double> builds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
builds.add(MediaQuery.viewInsetsOf(context).bottom);
|
||||
return const Material(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(width: 200, child: TextField()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late List<double> builds;
|
||||
|
||||
setUp(() => builds = []);
|
||||
|
||||
void useKeyboardView(WidgetTester tester) {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
}
|
||||
|
||||
double shownInset(WidgetTester tester) {
|
||||
return MediaQuery.viewInsetsOf(
|
||||
tester.element(find.byType(_InsetProbe)),
|
||||
).bottom;
|
||||
}
|
||||
|
||||
Future<void> setKeyboard(WidgetTester tester, double height) async {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: height);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> openDialog(WidgetTester tester) async {
|
||||
useKeyboardView(tester);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: KeyboardInsetHold(child: _InsetProbe(builds))),
|
||||
);
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: tester.element(find.byType(_InsetProbe)),
|
||||
builder: (_) => const Center(child: Text('dialog')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a page under a dialog sits out the keyboard it raises', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
builds.clear();
|
||||
|
||||
for (final height in [100.0, 200.0, 300.0]) {
|
||||
await setKeyboard(tester, height);
|
||||
}
|
||||
|
||||
expect(builds, isEmpty);
|
||||
expect(shownInset(tester), 0);
|
||||
});
|
||||
|
||||
testWidgets('an uncovered page waits for the keyboard to come down to it', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
await setKeyboard(tester, 300);
|
||||
|
||||
Navigator.of(tester.element(find.text('dialog'))).pop();
|
||||
await tester.pump();
|
||||
expect(shownInset(tester), 0);
|
||||
|
||||
await setKeyboard(tester, 150);
|
||||
expect(shownInset(tester), 0);
|
||||
|
||||
await setKeyboard(tester, 0);
|
||||
await setKeyboard(tester, 300);
|
||||
expect(shownInset(tester), 300);
|
||||
});
|
||||
|
||||
testWidgets('an uncovered page takes the keyboard once focus is inside', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
await setKeyboard(tester, 300);
|
||||
|
||||
Navigator.of(tester.element(find.text('dialog'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(shownInset(tester), 0);
|
||||
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
expect(shownInset(tester), 300);
|
||||
});
|
||||
|
||||
testWidgets('an inactive tab sits out the keyboard until it comes down', (
|
||||
tester,
|
||||
) async {
|
||||
Widget tab({required bool isActive}) {
|
||||
return MaterialApp(
|
||||
home: PageActivityScope(
|
||||
isActive: isActive,
|
||||
child: KeyboardInsetHold(child: _InsetProbe(builds)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
useKeyboardView(tester);
|
||||
await tester.pumpWidget(tab(isActive: false));
|
||||
await setKeyboard(tester, 300);
|
||||
expect(shownInset(tester), 0);
|
||||
|
||||
await tester.pumpWidget(tab(isActive: true));
|
||||
expect(shownInset(tester), 0);
|
||||
|
||||
await setKeyboard(tester, 0);
|
||||
await setKeyboard(tester, 300);
|
||||
expect(shownInset(tester), 300);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fl_clash/common/app_ports.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/pages/home.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/views/dashboard/widgets/start_button.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
const _mobileSize = Size(400, 800);
|
||||
|
||||
final _lens = find.descendant(
|
||||
of: find.byType(FloatingNavigationBar),
|
||||
matching: find.byType(PositionedDirectional),
|
||||
);
|
||||
const _addLabel = 'Add';
|
||||
|
||||
IconButtonData _addAction({bool isLoading = false}) {
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () {},
|
||||
tooltip: _addLabel,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _page(String title, {IconButtonData? primaryAction}) {
|
||||
return CommonScaffold(
|
||||
title: title,
|
||||
primaryAction: primaryAction,
|
||||
body: Text('page:$title'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProviderContainer> _pumpHome(
|
||||
WidgetTester tester, {
|
||||
List<Override> overrides = const [],
|
||||
bool settle = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = _mobileSize;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
profilesProvider.overrideWithValue([
|
||||
const Profile(id: 1, autoUpdateDuration: Duration.zero),
|
||||
]),
|
||||
suspendProvider.overrideWithValue(false),
|
||||
...overrides,
|
||||
navigationItemsStateProvider.overrideWithValue(
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => _page('dashboard'),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.profiles,
|
||||
label: PageLabel.profiles,
|
||||
builder: (_) => _page('profiles', primaryAction: _addAction()),
|
||||
),
|
||||
NavigationItem(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => _page('tools'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = _mobileSize;
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(includeNavigatorKey: false, child: HomePage()),
|
||||
),
|
||||
);
|
||||
if (settle) {
|
||||
await tester.pumpAndSettle();
|
||||
} else {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
// A press only paints, so its swell shows in the paint transform.
|
||||
Rect _painted(WidgetTester tester, RenderObject box, Rect resting) {
|
||||
Matrix4? transform;
|
||||
expect(
|
||||
box,
|
||||
paints..everything((method, arguments) {
|
||||
if (method == #transform) {
|
||||
transform ??= Matrix4.fromFloat64List(arguments.single as Float64List);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
final local = Offset.zero & resting.size;
|
||||
return (transform == null
|
||||
? local
|
||||
: MatrixUtils.transformRect(transform!, local))
|
||||
.shift(resting.topLeft);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
navigationPort = navigation;
|
||||
addTearDown(() => navigationPort = null);
|
||||
});
|
||||
|
||||
testWidgets('the start button holds the end of the dock on the dashboard', (
|
||||
tester,
|
||||
) async {
|
||||
final container = await _pumpHome(tester);
|
||||
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final button = tester.getRect(find.byType(FloatingActionButton));
|
||||
expect(find.byType(StartButton), findsOneWidget);
|
||||
expect(button.width, button.height);
|
||||
expect(button.height, bar.height);
|
||||
expect(button.center.dy, bar.center.dy);
|
||||
expect(button.left - bar.right, 8);
|
||||
expect((bar.left + button.right) / 2, _mobileSize.width / 2);
|
||||
|
||||
await tester.drag(
|
||||
find.byType(FloatingNavigationBar),
|
||||
Offset(bar.width * 0.7, 0),
|
||||
warnIfMissed: false,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
expect(find.byType(StartButton), findsNothing);
|
||||
final alone = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
expect(alone.center.dx, _mobileSize.width / 2);
|
||||
expect(alone.width, lessThan(_mobileSize.width - 40));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a tap that just misses the bar still reaches it', (
|
||||
tester,
|
||||
) async {
|
||||
final container = await _pumpHome(tester);
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final extent = (bar.width - 8) / 3;
|
||||
final tools = bar.left + 4 + extent * 2.5;
|
||||
|
||||
await tester.tapAt(Offset(tools, bar.bottom + 12));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
|
||||
await tester.tapAt(Offset(tools, bar.bottom + 6));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.tools);
|
||||
|
||||
final alone = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
await tester.tapAt(Offset(alone.left - 6, alone.top - 6));
|
||||
await tester.pumpAndSettle();
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
});
|
||||
|
||||
testWidgets('the lens keeps up with a finger that never pauses', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHome(tester);
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final extent = (bar.width - 8) / 3;
|
||||
var finger = Offset(bar.left + 4 + extent / 2, bar.center.dy);
|
||||
final gesture = await tester.startGesture(finger);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
for (var i = 0; i < 30; i++) {
|
||||
finger += Offset(extent * 2 / 30, 0);
|
||||
await gesture.moveTo(finger);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
|
||||
expect(tester.getCenter(_lens).dx, closeTo(finger.dx, extent * 0.3));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('a flick carries the lens on to the next item', (tester) async {
|
||||
final container = await _pumpHome(tester);
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final extent = (bar.width - 8) / 3;
|
||||
final start = Offset(bar.left + 4 + extent / 2, bar.center.dy);
|
||||
|
||||
Future<void> drag(Duration duration) async {
|
||||
await tester.timedDragFrom(start, Offset(extent * 0.35, 0), duration);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await drag(const Duration(seconds: 1));
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.dashboard);
|
||||
|
||||
await drag(const Duration(milliseconds: 50));
|
||||
expect(container.read(currentPageLabelProvider), PageLabel.profiles);
|
||||
});
|
||||
|
||||
testWidgets('a press lifts the lens past the bar until it is let go', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHome(tester);
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final resting = tester.getRect(_lens);
|
||||
expect(resting.height, bar.height - 8);
|
||||
|
||||
final gesture = await tester.startGesture(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final lifted = tester.getRect(_lens);
|
||||
expect(lifted.top, lessThan(bar.top));
|
||||
expect(lifted.bottom, greaterThan(bar.bottom));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getRect(_lens), resting);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the docked start button swells and follows a drag like the lens',
|
||||
(tester) async {
|
||||
await _pumpHome(tester);
|
||||
final press = find.ancestor(
|
||||
of: find.byType(FloatingActionButton),
|
||||
matching: find.byType(ElasticPress),
|
||||
);
|
||||
final resting = tester.getRect(press);
|
||||
Rect painted() => _painted(
|
||||
tester,
|
||||
tester.renderObject<RenderProxyBox>(press).child!,
|
||||
resting,
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final pressed = painted();
|
||||
expect(tester.getRect(press), resting);
|
||||
expect(pressed.center, resting.center);
|
||||
expect(pressed.width, greaterThan(resting.width + 8));
|
||||
expect(
|
||||
pressed.width / pressed.height,
|
||||
moreOrLessEquals(resting.width / resting.height),
|
||||
);
|
||||
|
||||
await gesture.moveBy(const Offset(-60, 0));
|
||||
await tester.pumpAndSettle();
|
||||
final pulled = painted();
|
||||
expect(pulled.center.dx, lessThan(resting.center.dx - 4));
|
||||
expect(pulled.width / pulled.height, greaterThan(1));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(painted(), resting);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the bar swells under a pointer and stretches past its end', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHome(tester);
|
||||
final bar = find.byType(FloatingNavigationBar);
|
||||
final resting = tester.getRect(bar);
|
||||
Rect painted() => _painted(tester, tester.renderObject(bar), resting);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: Offset.zero);
|
||||
await mouse.moveTo(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final hovered = painted();
|
||||
expect(hovered.center, resting.center);
|
||||
expect(hovered.width, greaterThan(resting.width));
|
||||
|
||||
await mouse.down(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
final pressed = painted();
|
||||
expect(tester.getRect(bar), resting);
|
||||
expect(pressed.center, resting.center);
|
||||
expect(pressed.width, greaterThan(hovered.width));
|
||||
expect(pressed.height, greaterThan(resting.height));
|
||||
|
||||
await mouse.moveTo(resting.centerRight + const Offset(80, 0));
|
||||
await tester.pumpAndSettle();
|
||||
final pulled = painted();
|
||||
expect(pulled.center.dx, greaterThan(resting.center.dx + 4));
|
||||
expect(pulled.width, greaterThan(pressed.width));
|
||||
|
||||
await mouse.up();
|
||||
await mouse.moveTo(Offset.zero);
|
||||
await tester.pumpAndSettle();
|
||||
expect(painted(), resting);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a hovering pointer highlights and draws only the item under it',
|
||||
(tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
overrides: [
|
||||
currentPageLabelProvider.overrideWithBuild((_, _) => PageLabel.tools),
|
||||
],
|
||||
);
|
||||
final bar = find.byType(FloatingNavigationBar);
|
||||
final tools = find
|
||||
.descendant(of: bar, matching: find.byType(GlyphIcon))
|
||||
.last;
|
||||
final resting = tester.getRect(tools);
|
||||
// The lens is drawn first, so the highlight is the last positioned child.
|
||||
final positioned = find.descendant(
|
||||
of: bar,
|
||||
matching: find.byType(Positioned),
|
||||
);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: Offset.zero);
|
||||
await mouse.moveTo(resting.center + const Offset(30, 0));
|
||||
await tester.pumpAndSettle();
|
||||
final hovered = tester.getRect(tools);
|
||||
expect(hovered.width, moreOrLessEquals(resting.width));
|
||||
expect(positioned, findsNWidgets(2));
|
||||
final highlight =
|
||||
tester.getCenter(positioned.last).dx - resting.center.dx;
|
||||
final drawn = hovered.center.dx - resting.center.dx;
|
||||
expect(drawn, greaterThan(0.5));
|
||||
expect(drawn, moreOrLessEquals(highlight / 2, epsilon: 0.5));
|
||||
expect(highlight, lessThan(30));
|
||||
expect(
|
||||
tester.getCenter(positioned.first).dx,
|
||||
moreOrLessEquals(tester.getCenter(positioned.last).dx),
|
||||
);
|
||||
|
||||
await mouse.moveTo(resting.center - const Offset(80, 0));
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(
|
||||
tester.getRect(tools).center.dx,
|
||||
inInclusiveRange(resting.center.dx - 0.5, hovered.center.dx),
|
||||
);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
|
||||
|
||||
await mouse.moveTo(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await mouse.down(resting.center);
|
||||
await tester.pumpAndSettle();
|
||||
expect(positioned, findsOneWidget);
|
||||
await mouse.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(positioned, findsNWidgets(2));
|
||||
|
||||
await mouse.moveTo(Offset.zero);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getCenter(tools).dx, moreOrLessEquals(resting.center.dx));
|
||||
expect(positioned, findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a tap leaves no highlight behind', (tester) async {
|
||||
await _pumpHome(tester);
|
||||
final bar = find.byType(FloatingNavigationBar);
|
||||
final tools = find
|
||||
.descendant(of: bar, matching: find.byType(GlyphIcon))
|
||||
.last;
|
||||
final positioned = find.descendant(
|
||||
of: bar,
|
||||
matching: find.byType(Positioned),
|
||||
);
|
||||
|
||||
final finger = await tester.createGesture();
|
||||
addTearDown(finger.removePointer);
|
||||
await finger.addPointer(location: Offset.zero);
|
||||
await finger.moveTo(tester.getCenter(tools));
|
||||
await tester.pumpAndSettle();
|
||||
expect(positioned, findsOneWidget);
|
||||
|
||||
await finger.down(tester.getCenter(tools));
|
||||
await finger.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(positioned, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a phone page shows its primary action in its bar', (
|
||||
tester,
|
||||
) async {
|
||||
final container = await _pumpHome(tester);
|
||||
container
|
||||
.read(currentPageLabelProvider.notifier)
|
||||
.toPage(PageLabel.profiles);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final action = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is AppBarActionButton && widget.data.tooltip == _addLabel,
|
||||
);
|
||||
expect(action, findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(of: action, matching: find.byType(AppBar)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CommonScaffold),
|
||||
matching: find.byType(FloatingActionButton),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'the docked start button stays an icon-only circle while it runs',
|
||||
(tester) async {
|
||||
await _pumpHome(
|
||||
tester,
|
||||
settle: false,
|
||||
overrides: [
|
||||
runTimeProvider.overrideWithBuild(
|
||||
(_, _) => const Duration(
|
||||
hours: 2,
|
||||
minutes: 13,
|
||||
seconds: 8,
|
||||
).inMilliseconds,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final bar = tester.getRect(find.byType(FloatingNavigationBar));
|
||||
final button = tester.getRect(find.byType(FloatingActionButton));
|
||||
expect(button.width, button.height);
|
||||
expect(button.height, bar.height);
|
||||
expect(find.text('02:13:08'), findsNothing);
|
||||
expect(find.byType(RunTimeText), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a page off the dock shows its primary action as a FAB', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: _page('page', primaryAction: _addAction()),
|
||||
),
|
||||
);
|
||||
|
||||
final fab = find.byType(FloatingActionButton);
|
||||
expect(fab, findsOneWidget);
|
||||
expect(find.text(_addLabel), findsOneWidget);
|
||||
expect(find.byType(AppBarActionButton), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: _page('page', primaryAction: _addAction(isLoading: true)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedScale>(
|
||||
find.ancestor(of: fab, matching: find.byType(AnimatedScale)),
|
||||
)
|
||||
.scale,
|
||||
0,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('four destinations keep to their width and five fill the row', (
|
||||
tester,
|
||||
) async {
|
||||
const size = Size(560, 800);
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
Future<Rect> pumpBar(int count) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: NavigationDock(
|
||||
destinations: [
|
||||
for (var index = 0; index < count; index++)
|
||||
NavigationDockDestination(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: '$index',
|
||||
),
|
||||
],
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return tester.getRect(find.byType(FloatingNavigationBar));
|
||||
}
|
||||
|
||||
final four = await pumpBar(4);
|
||||
expect(four.center.dx, size.width / 2);
|
||||
expect(four.width, lessThan(size.width - 42));
|
||||
final five = await pumpBar(5);
|
||||
expect(five.left, 21);
|
||||
expect(five.right, size.width - 21);
|
||||
});
|
||||
|
||||
testWidgets('a long label shrinks to fit, then ellipsizes behind a tooltip', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(360, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
Future<List<Text>> pumpLabels(String label) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: NavigationDock(
|
||||
destinations: [
|
||||
for (final text in ['Home', 'Apps', 'Logs', label])
|
||||
NavigationDockDestination(
|
||||
glyph: AppGlyphs.tools,
|
||||
label: text,
|
||||
),
|
||||
],
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return tester
|
||||
.widgetList<Text>(
|
||||
find.descendant(
|
||||
of: find.byType(FloatingNavigationBar),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final tooltips = find.descendant(
|
||||
of: find.byType(FloatingNavigationBar),
|
||||
matching: find.byType(Tooltip),
|
||||
);
|
||||
|
||||
var labels = await pumpLabels('Profile');
|
||||
final shrunk = labels.first.style!.fontSize!;
|
||||
expect(shrunk, lessThan(10));
|
||||
expect(shrunk, greaterThan(9));
|
||||
expect(labels.map((text) => text.style!.fontSize).toSet(), {shrunk});
|
||||
expect(tooltips, findsNothing);
|
||||
|
||||
labels = await pumpLabels('Configuration');
|
||||
expect(labels.first.style!.fontSize, closeTo(9, 0.001));
|
||||
expect(tester.widget<Tooltip>(tooltips).message, 'Configuration');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'an elastic button leaves a press to the swell and keeps the icon theme',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Scaffold(
|
||||
body: IconTheme.merge(
|
||||
data: const IconThemeData(fill: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
ElasticButton(
|
||||
child: IconButton.filled(
|
||||
tooltip: _addLabel,
|
||||
onPressed: () {},
|
||||
icon: const GlyphIcon(AppGlyphs.add),
|
||||
),
|
||||
),
|
||||
ElasticButton(
|
||||
child: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text(_addLabel),
|
||||
),
|
||||
),
|
||||
ElasticButton(
|
||||
child: FloatingActionButton(
|
||||
heroTag: null,
|
||||
onPressed: () {},
|
||||
child: const GlyphIcon(AppGlyphs.copy),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
InkWell inkOf(Type button) => tester.widget<InkWell>(
|
||||
find.descendant(
|
||||
of: find.byType(button),
|
||||
matching: find.byType(InkWell),
|
||||
),
|
||||
);
|
||||
for (final button in [IconButton, FilledButton]) {
|
||||
final ink = inkOf(button);
|
||||
expect(ink.splashFactory, NoSplash.splashFactory);
|
||||
expect(
|
||||
ink.overlayColor!.resolve({WidgetState.pressed}),
|
||||
Colors.transparent,
|
||||
);
|
||||
}
|
||||
final fabInk = find.descendant(
|
||||
of: find.byType(FloatingActionButton),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
final fabTheme = Theme.of(tester.element(fabInk));
|
||||
expect(fabTheme.splashFactory, NoSplash.splashFactory);
|
||||
expect(fabTheme.highlightColor, Colors.transparent);
|
||||
expect(
|
||||
IconTheme.of(tester.element(find.byType(GlyphIcon).first)).fill,
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,86 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('morphs the closed shape and color into the open page', (
|
||||
tester,
|
||||
) async {
|
||||
const closedShape = RoundedSuperellipseBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 160,
|
||||
height: 80,
|
||||
child: OpenContainer<void>(
|
||||
closedShape: closedShape,
|
||||
closedColor: Colors.red,
|
||||
openColor: Colors.blue,
|
||||
curve: Curves.linear,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
closedBuilder: (_, open) {
|
||||
return GestureDetector(
|
||||
onTap: open,
|
||||
child: const Text('Card'),
|
||||
);
|
||||
},
|
||||
openBuilder: (_, close) {
|
||||
return GestureDetector(
|
||||
onTap: () => close(),
|
||||
child: const Text('Page'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Material container() {
|
||||
return tester.widget<Material>(
|
||||
find
|
||||
.ancestor(of: find.text('Page'), matching: find.byType(Material))
|
||||
.first,
|
||||
);
|
||||
}
|
||||
|
||||
double corner(ShapeBorder? shape) {
|
||||
final border = shape! as RoundedSuperellipseBorder;
|
||||
return border.borderRadius.resolve(TextDirection.ltr).topLeft.x;
|
||||
}
|
||||
|
||||
final closedMaterial = tester.widget<Material>(
|
||||
find
|
||||
.ancestor(of: find.text('Card'), matching: find.byType(Material))
|
||||
.first,
|
||||
);
|
||||
expect(closedMaterial.shape, closedShape);
|
||||
|
||||
await tester.tap(find.text('Card'));
|
||||
await tester.pump();
|
||||
expect(corner(container().shape), 24);
|
||||
expect(container().color, Colors.red);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(corner(container().shape), closeTo(12, 0.01));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(container().shape, isNull);
|
||||
|
||||
await tester.tap(find.text('Page'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(corner(container().shape), closeTo(12, 0.01));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Page'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'fadeThrough opens by callback and supports interrupted reverse',
|
||||
(tester) async {
|
||||
|
||||
+439
-28
@@ -1,12 +1,47 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/popup.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final surfaces = find.byWidgetPredicate(
|
||||
(widget) => widget is Material && widget.type == MaterialType.card,
|
||||
);
|
||||
|
||||
double shadowOf(WidgetTester tester, Finder surface) {
|
||||
final box = tester.widget<DecoratedBox>(
|
||||
find
|
||||
.ancestor(
|
||||
of: surface,
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is DecoratedBox &&
|
||||
widget.decoration is ShapeDecoration &&
|
||||
(widget.decoration as ShapeDecoration).shadows != null,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final shadows = (box.decoration as ShapeDecoration).shadows!;
|
||||
return shadows.fold(0.0, (sum, shadow) => sum + shadow.color.a);
|
||||
}
|
||||
|
||||
Rect shrinkingContent(WidgetTester tester) {
|
||||
final content = tester.layers.whereType<ImageFilterLayer>().last;
|
||||
final transform = content.firstChild! as TransformLayer;
|
||||
final popup = tester.getRect(find.byKey(const Key('popup')));
|
||||
return MatrixUtils.transformRect(
|
||||
transform.transform!,
|
||||
Offset.zero & popup.size,
|
||||
).shift(popup.topLeft);
|
||||
}
|
||||
|
||||
Future<PopupOpen> pumpBox(WidgetTester tester) async {
|
||||
late PopupOpen open;
|
||||
await tester.pumpWidget(
|
||||
@@ -125,6 +160,262 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a point placement opens the popup from that point', (
|
||||
tester,
|
||||
) async {
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).push(
|
||||
CommonPopupRoute<void>(
|
||||
barrierLabel: 'Dismiss',
|
||||
placement: PopupPlacement.belowPoint,
|
||||
anchorOf: () => const Offset(100, 120) & Size.zero,
|
||||
builder: (_) =>
|
||||
const SizedBox(width: 80, height: 80, key: Key('popup')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const Key('popup'))),
|
||||
const Offset(100, 120),
|
||||
);
|
||||
});
|
||||
|
||||
for (final (name, point, avoid, top) in [
|
||||
('flips above a point too close to the bottom', 560.0, null, 480.0),
|
||||
(
|
||||
'sits above the rect it avoids',
|
||||
250.0,
|
||||
const Rect.fromLTRB(0, 200, 800, 300),
|
||||
112.0,
|
||||
),
|
||||
(
|
||||
'drops below the rect it avoids when above is full',
|
||||
250.0,
|
||||
const Rect.fromLTRB(0, 40, 800, 300),
|
||||
308.0,
|
||||
),
|
||||
]) {
|
||||
testWidgets('a point placement $name', (tester) async {
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).push(
|
||||
CommonPopupRoute<void>(
|
||||
barrierLabel: 'Dismiss',
|
||||
placement: PopupPlacement.belowPoint,
|
||||
anchorOf: () => Offset(100, point) & Size.zero,
|
||||
avoid: avoid,
|
||||
builder: (_) =>
|
||||
const SizedBox(width: 80, height: 80, key: Key('popup')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(find.byKey(const Key('popup'))).dy, top);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'a point placement flips above a point the keyboard would cover',
|
||||
(tester) async {
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 900);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).push(
|
||||
CommonPopupRoute<void>(
|
||||
barrierLabel: 'Dismiss',
|
||||
placement: PopupPlacement.belowPoint,
|
||||
anchorOf: () => const Offset(100, 250) & Size.zero,
|
||||
builder: (_) =>
|
||||
const SizedBox(width: 80, height: 80, key: Key('popup')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getBottomLeft(find.byKey(const Key('popup'))).dy, 250);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a non-modal popup lets touches and focus stay on the page', (
|
||||
tester,
|
||||
) async {
|
||||
var taps = 0;
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
late BuildContext context;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => taps++,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
Navigator.of(context).push(
|
||||
CommonPopupRoute<void>(
|
||||
barrierLabel: 'Dismiss',
|
||||
placement: PopupPlacement.belowPoint,
|
||||
modal: false,
|
||||
anchorOf: () => const Offset(100, 120) & Size.zero,
|
||||
builder: (_) =>
|
||||
const SizedBox(width: 80, height: 80, key: Key('popup')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(taps, 1);
|
||||
expect(find.byKey(const Key('popup')), findsNothing);
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('only a checked item carries a check', (tester) async {
|
||||
final open = await pumpMenu(tester, [
|
||||
const CommonPopupMenuItem(label: 'on', checked: true),
|
||||
const CommonPopupMenuItem(label: 'off', checked: false),
|
||||
const CommonPopupMenuItem(label: 'plain'),
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.ancestor(of: find.text('on'), matching: find.byType(Row)),
|
||||
matching: find.byGlyph(AppGlyphs.check),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byGlyph(AppGlyphs.check), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the popup grows out of its anchor and closes back into it', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpBox(tester);
|
||||
final anchor = tester.getRect(find.byType(CommonPopupBox));
|
||||
double sourceOpacity() => tester
|
||||
.widget<Opacity>(
|
||||
find.descendant(
|
||||
of: find.byType(CommonPopupBox),
|
||||
matching: find.byType(Opacity),
|
||||
),
|
||||
)
|
||||
.opacity;
|
||||
Rect? platter() {
|
||||
final clips = tester.layers.whereType<ClipPathLayer>();
|
||||
return clips.isEmpty ? null : clips.last.clipPath!.getBounds();
|
||||
}
|
||||
|
||||
open();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final opening = platter()!;
|
||||
final popup = tester.getRect(find.byKey(const Key('popup')));
|
||||
expect(opening.width, lessThan(popup.width));
|
||||
expect(opening.height, lessThan(popup.height));
|
||||
expect(opening.overlaps(anchor), isTrue);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(platter(), isNull);
|
||||
expect(sourceOpacity(), 0);
|
||||
|
||||
Navigator.of(tester.element(find.byKey(const Key('popup')))).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
|
||||
final closing = shrinkingContent(tester);
|
||||
expect(closing.width, lessThan(popup.width));
|
||||
expect(closing.overlaps(anchor), isTrue);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('popup')), findsNothing);
|
||||
expect(sourceOpacity(), 1);
|
||||
});
|
||||
|
||||
testWidgets('the platter flies to the popup center on an arc', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpBox(tester);
|
||||
final anchor = tester.getRect(find.byType(CommonPopupBox)).center;
|
||||
Offset platter() => tester.layers
|
||||
.whereType<ClipPathLayer>()
|
||||
.last
|
||||
.clipPath!
|
||||
.getBounds()
|
||||
.center;
|
||||
|
||||
open();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
|
||||
final opening = platter();
|
||||
final popup = tester.getRect(find.byKey(const Key('popup'))).center;
|
||||
final across = (opening.dx - anchor.dx) / (popup.dx - anchor.dx);
|
||||
final down = (opening.dy - anchor.dy) / (popup.dy - anchor.dy);
|
||||
expect(across, greaterThan(0));
|
||||
expect(down, greaterThan(across + 0.1));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(tester.element(find.byKey(const Key('popup')))).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final closing = shrinkingContent(tester).center;
|
||||
final back = (popup.dx - closing.dx) / (popup.dx - anchor.dx);
|
||||
final up = (popup.dy - closing.dy) / (popup.dy - anchor.dy);
|
||||
expect(back, greaterThan(up + 0.1));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('dismissing the barrier closes the popup', (tester) async {
|
||||
final open = await pumpBox(tester);
|
||||
open();
|
||||
@@ -146,16 +437,39 @@ void main() {
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
|
||||
expect(find.byGlyph(AppGlyphs.chevronForward), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('child'), findsOneWidget);
|
||||
expect(find.byType(Card), findsNWidgets(2));
|
||||
expect(surfaces, findsNWidgets(2));
|
||||
expect(find.text('parent'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('a sub menu opened mid-flight is not held in the platter', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpMenu(tester, [
|
||||
const CommonPopupMenuItem(
|
||||
label: 'parent',
|
||||
subItems: [CommonPopupMenuItem(label: 'child')],
|
||||
),
|
||||
]);
|
||||
bool inPlatter() => tester.layers.whereType<ImageFilterLayer>().isNotEmpty;
|
||||
|
||||
open();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(inPlatter(), isTrue);
|
||||
|
||||
await tester.tap(find.text('parent'), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
|
||||
expect(inPlatter(), isFalse);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('the sub menu header lands exactly on the opening item', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -182,7 +496,7 @@ void main() {
|
||||
final open = await pumpMenu(tester, [CommonPopupMenuItem(label: label)]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
final width = tester.getSize(find.byType(Card)).width;
|
||||
final width = tester.getSize(surfaces).width;
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pumpAndSettle();
|
||||
return width;
|
||||
@@ -213,7 +527,18 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Divider), findsOneWidget);
|
||||
expect(tester.getSize(find.byType(Divider)).height, greaterThan(0));
|
||||
final header = find.ancestor(
|
||||
of: find.text('parent').last,
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
final divider = find.byType(Divider);
|
||||
expect(tester.getTopLeft(divider).dy, tester.getBottomLeft(header).dy);
|
||||
expect(tester.getSize(divider).height, 17);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('child')).dy -
|
||||
tester.getBottomLeft(divider).dy,
|
||||
greaterThanOrEqualTo(12),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the header folds the sub menu back into its item', (
|
||||
@@ -325,7 +650,7 @@ void main() {
|
||||
final open = await pumpMenu(tester, [
|
||||
CommonPopupMenuItem(label: 'plain', onPressed: () {}),
|
||||
CommonPopupMenuItem(
|
||||
icon: Icons.delete_outlined,
|
||||
glyph: AppGlyphs.delete,
|
||||
label: 'delete',
|
||||
danger: true,
|
||||
onPressed: () {},
|
||||
@@ -343,7 +668,7 @@ void main() {
|
||||
colorScheme.error,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Icon>(find.byIcon(Icons.delete_outlined)).color,
|
||||
tester.widget<GlyphIcon>(find.byGlyph(AppGlyphs.delete)).color,
|
||||
colorScheme.error,
|
||||
);
|
||||
expect(
|
||||
@@ -393,9 +718,9 @@ void main() {
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Card), findsNWidgets(2));
|
||||
final parentWidth = tester.getSize(find.byType(Card).first).width;
|
||||
final childWidth = tester.getSize(find.byType(Card).last).width;
|
||||
expect(surfaces, findsNWidgets(2));
|
||||
final parentWidth = tester.getSize(surfaces.first).width;
|
||||
final childWidth = tester.getSize(surfaces.last).width;
|
||||
expect(childWidth, greaterThanOrEqualTo(parentWidth));
|
||||
});
|
||||
|
||||
@@ -411,14 +736,11 @@ void main() {
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final elevations = tester
|
||||
.widgetList<Card>(find.byType(Card))
|
||||
.map((card) => card.elevation)
|
||||
.toList();
|
||||
expect(elevations, hasLength(2));
|
||||
expect(elevations[0], isNotNull);
|
||||
expect(elevations[1], isNotNull);
|
||||
expect(elevations[0]!, lessThan(elevations[1]!));
|
||||
expect(surfaces, findsNWidgets(2));
|
||||
expect(
|
||||
shadowOf(tester, surfaces.first),
|
||||
lessThan(shadowOf(tester, surfaces.last)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a sub menu settles wider than the parent it opened from', (
|
||||
@@ -432,13 +754,13 @@ void main() {
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
final parentWidth = tester.getSize(find.byType(Card)).width;
|
||||
final parentWidth = tester.getSize(surfaces).width;
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byType(Card).last).width,
|
||||
tester.getSize(surfaces.last).width,
|
||||
closeTo(parentWidth * 1.12, 0.01),
|
||||
);
|
||||
});
|
||||
@@ -454,14 +776,14 @@ void main() {
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
final parentWidth = tester.getSize(find.byType(Card)).width;
|
||||
final parentWidth = tester.getSize(surfaces).width;
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pump();
|
||||
|
||||
final active = find.byType(Card).last;
|
||||
final active = surfaces.last;
|
||||
expect(tester.getSize(active).width, parentWidth);
|
||||
expect(tester.widget<Card>(active).elevation, 0);
|
||||
expect(shadowOf(tester, active), 0);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
final midWidth = tester.getSize(active).width;
|
||||
@@ -486,13 +808,13 @@ void main() {
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
final parentWidth = tester.getSize(find.byType(Card)).width;
|
||||
final parentWidth = tester.getSize(surfaces).width;
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
final active = find.byType(Card).last;
|
||||
final active = surfaces.last;
|
||||
final midSize = tester.getSize(active);
|
||||
expect(midSize.width, greaterThanOrEqualTo(parentWidth * 1.12));
|
||||
|
||||
@@ -520,14 +842,14 @@ void main() {
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pump();
|
||||
final foldedSize = tester.getSize(find.byType(Card).last);
|
||||
final foldedSize = tester.getSize(surfaces.last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('parent').last);
|
||||
Size? lastSize;
|
||||
var frames = 0;
|
||||
while (find.byType(Card).evaluate().length > 1) {
|
||||
lastSize = tester.getSize(find.byType(Card).last);
|
||||
while (surfaces.evaluate().length > 1) {
|
||||
lastSize = tester.getSize(surfaces.last);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
frames++;
|
||||
expect(frames, lessThan(60));
|
||||
@@ -537,4 +859,93 @@ void main() {
|
||||
expect(lastSize.height, closeTo(foldedSize.height, 1));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('every level draws its shadow on the frame it is asked for', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpMenu(tester, [
|
||||
const CommonPopupMenuItem(
|
||||
label: 'parent',
|
||||
subItems: [CommonPopupMenuItem(label: 'child')],
|
||||
),
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final durations = tester
|
||||
.widgetList<Material>(surfaces)
|
||||
.map((surface) => surface.animationDuration)
|
||||
.toSet();
|
||||
expect(durations, {Duration.zero});
|
||||
});
|
||||
|
||||
testWidgets('the parent hands its shadow over without a step', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpMenu(tester, [
|
||||
const CommonPopupMenuItem(
|
||||
label: 'parent',
|
||||
subItems: [CommonPopupMenuItem(label: 'child')],
|
||||
),
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
final restingShadow = shadowOf(tester, surfaces);
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pump();
|
||||
expect(shadowOf(tester, surfaces.first), restingShadow);
|
||||
expect(shadowOf(tester, surfaces.last), 0);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(shadowOf(tester, surfaces.first), lessThan(restingShadow));
|
||||
expect(shadowOf(tester, surfaces.last), restingShadow);
|
||||
|
||||
await tester.tap(find.text('parent').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(shadowOf(tester, surfaces), restingShadow);
|
||||
});
|
||||
|
||||
testWidgets('a parent keeps its scroll offset while a sub menu is open', (
|
||||
tester,
|
||||
) async {
|
||||
final open = await pumpMenu(tester, [
|
||||
for (var index = 0; index < 4; index++)
|
||||
CommonPopupMenuItem(label: 'item $index'),
|
||||
const CommonPopupMenuItem(
|
||||
label: 'parent',
|
||||
subItems: [CommonPopupMenuItem(label: 'child')],
|
||||
),
|
||||
for (var index = 4; index < 30; index++)
|
||||
CommonPopupMenuItem(label: 'item $index'),
|
||||
]);
|
||||
open();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||
scrollable.position.jumpTo(100);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('parent'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('child'), findsOneWidget);
|
||||
final parentScrollable = tester.state<ScrollableState>(
|
||||
find.byType(Scrollable).first,
|
||||
);
|
||||
expect(identical(parentScrollable, scrollable), isTrue);
|
||||
expect(parentScrollable.position.pixels, 100);
|
||||
|
||||
await tester.tap(find.text('parent').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
identical(
|
||||
tester.state<ScrollableState>(find.byType(Scrollable)),
|
||||
scrollable,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(scrollable.position.pixels, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
Widget _buildApp({required bool enabled, required Widget home}) {
|
||||
return MaterialApp(
|
||||
builder: (context, child) {
|
||||
return RemoteFocusAdapter(enabled: enabled, child: child!);
|
||||
},
|
||||
home: home,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const TextField(key: ValueKey('name')),
|
||||
const TextField(key: ValueKey('url'), minLines: 1, maxLines: 5),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const ValueKey('cancel'),
|
||||
onPressed: () {},
|
||||
child: const Text('cancel'),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('submit'),
|
||||
onPressed: () {},
|
||||
child: const Text('submit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasFocus(WidgetTester tester, Key key) {
|
||||
final editable = find.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byType(EditableText),
|
||||
);
|
||||
if (editable.evaluate().isNotEmpty) {
|
||||
return tester
|
||||
.state<EditableTextState>(editable)
|
||||
.widget
|
||||
.focusNode
|
||||
.hasPrimaryFocus;
|
||||
}
|
||||
final label = find.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byType(Text),
|
||||
);
|
||||
return Focus.of(tester.element(label.first)).hasPrimaryFocus;
|
||||
}
|
||||
|
||||
bool _onActionRow(WidgetTester tester) {
|
||||
return _hasFocus(tester, const ValueKey('cancel')) ||
|
||||
_hasFocus(tester, const ValueKey('submit'));
|
||||
}
|
||||
|
||||
TextEditingController _controllerOf(WidgetTester tester, Key key) {
|
||||
return tester
|
||||
.state<EditableTextState>(
|
||||
find.descendant(
|
||||
of: find.byKey(key),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
)
|
||||
.widget
|
||||
.controller;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a pushed dialog focuses its first control', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
enabled: true,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open'),
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('title'),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const ValueKey('ok'),
|
||||
onPressed: () {},
|
||||
child: const Text('ok'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('open')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_hasFocus(tester, const ValueKey('ok')), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a pushed dialog keeps focus on its scope when disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
enabled: false,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
key: const ValueKey('open'),
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const ValueKey('ok'),
|
||||
onPressed: () {},
|
||||
child: const Text('ok'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('open')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_hasFocus(tester, const ValueKey('ok')), isFalse);
|
||||
expect(FocusManager.instance.primaryFocus, isA<FocusScopeNode>());
|
||||
});
|
||||
|
||||
testWidgets('down leaves an empty field for the control below', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
|
||||
await tester.tap(find.byKey(const ValueKey('url')));
|
||||
await tester.pump();
|
||||
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(_onActionRow(tester), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('up leaves the first line for the field above', (tester) async {
|
||||
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
|
||||
await tester.enterText(find.byKey(const ValueKey('url')), 'https://a.b');
|
||||
await tester.pump();
|
||||
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
|
||||
expect(_hasFocus(tester, const ValueKey('name')), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('left inside the text moves the caret, at the start it leaves', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_buildApp(enabled: true, home: _buildForm()));
|
||||
await tester.enterText(find.byKey(const ValueKey('url')), 'ab');
|
||||
await tester.pump();
|
||||
final controller = _controllerOf(tester, const ValueKey('url'));
|
||||
controller.selection = const TextSelection.collapsed(offset: 1);
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||
await tester.pump();
|
||||
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
|
||||
expect(controller.selection.baseOffset, 0);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
expect(_onActionRow(tester), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('arrows stay in the field when disabled', (tester) async {
|
||||
await tester.pumpWidget(_buildApp(enabled: false, home: _buildForm()));
|
||||
await tester.tap(find.byKey(const ValueKey('url')));
|
||||
await tester.pump();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:fl_clash/widgets/route_motion_hold.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class _Feed extends StatefulWidget {
|
||||
const _Feed({required this.source});
|
||||
|
||||
final ValueNotifier<int> source;
|
||||
|
||||
@override
|
||||
State<_Feed> createState() => _FeedState();
|
||||
}
|
||||
|
||||
class _FeedState extends State<_Feed> with RouteMotionHoldMixin<_Feed> {
|
||||
late int _shown = widget.source.value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.source.addListener(_handleSource);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.source.removeListener(_handleSource);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSource() {
|
||||
updateWhenRouteSettled(() => setState(() => _shown = widget.source.value));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(body: Text('shown $_shown'));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ValueNotifier<int> source;
|
||||
late GlobalKey<NavigatorState> navigator;
|
||||
|
||||
setUp(() {
|
||||
source = ValueNotifier(0);
|
||||
navigator = GlobalKey<NavigatorState>();
|
||||
});
|
||||
|
||||
tearDown(() => source.dispose());
|
||||
|
||||
Future<void> pumpFeed(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigator,
|
||||
home: _Feed(source: source),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('applies updates at once while the route is still', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFeed(tester);
|
||||
|
||||
source.value = 1;
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('shown 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('holds updates while covered and replays the latest', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFeed(tester);
|
||||
|
||||
navigator.currentState!.push(
|
||||
MaterialPageRoute<void>(builder: (_) => const Scaffold()),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
source.value = 1;
|
||||
source.value = 2;
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('shown 0', skipOffstage: false), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('shown 2', skipOffstage: false), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('holds updates through a user gesture', (tester) async {
|
||||
await pumpFeed(tester);
|
||||
|
||||
navigator.currentState!.didStartUserGesture();
|
||||
await tester.pump();
|
||||
source.value = 3;
|
||||
await tester.pump();
|
||||
expect(find.text('shown 0'), findsOneWidget);
|
||||
|
||||
navigator.currentState!.didStopUserGesture();
|
||||
await tester.pump();
|
||||
expect(find.text('shown 3'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Future<List<String>> _pumpBar(
|
||||
WidgetTester tester, {
|
||||
bool hasSearch = false,
|
||||
String? primary,
|
||||
bool primaryLoading = false,
|
||||
bool foldPrimary = false,
|
||||
List<String> icons = const [],
|
||||
List<String> menuItems = const [],
|
||||
List<String> selection = const [],
|
||||
}) async {
|
||||
final taps = <String>[];
|
||||
IconButtonData action(String label, {bool isLoading = false}) {
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () => taps.add(label),
|
||||
tooltip: label,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: DockedPageScope(
|
||||
docked: true,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
searchState: hasSearch ? AppBarSearchState(onSearch: (_) {}) : null,
|
||||
primaryAction: primary == null
|
||||
? null
|
||||
: action(primary, isLoading: primaryLoading),
|
||||
iconActions: [for (final label in icons) action(label)],
|
||||
foldPrimaryAction: foldPrimary,
|
||||
selectionActions: [for (final label in selection) action(label)],
|
||||
menuItems: [
|
||||
for (final label in menuItems)
|
||||
CommonPopupMenuItem(
|
||||
label: label,
|
||||
onPressed: () => taps.add(label),
|
||||
),
|
||||
],
|
||||
body: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return taps;
|
||||
}
|
||||
|
||||
Finder _inBar(Finder finder) =>
|
||||
find.descendant(of: find.byType(AppBar), matching: finder);
|
||||
|
||||
List<String> _barButtons(WidgetTester tester) => [
|
||||
for (final button in tester.widgetList<IconButton>(
|
||||
_inBar(find.byType(IconButton)),
|
||||
))
|
||||
button.tooltip ?? '',
|
||||
];
|
||||
|
||||
Future<void> _openMenu(WidgetTester tester) async {
|
||||
await tester.tap(_inBar(find.byGlyph(AppGlyphs.more)));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
List<String> _menuLabels(WidgetTester tester, List<String> labels) {
|
||||
final shown = [
|
||||
for (final label in labels)
|
||||
if (find.text(label).evaluate().isNotEmpty) label,
|
||||
];
|
||||
return shown..sort(
|
||||
(a, b) => tester
|
||||
.getTopLeft(find.text(a))
|
||||
.dy
|
||||
.compareTo(tester.getTopLeft(find.text(b)).dy),
|
||||
);
|
||||
}
|
||||
|
||||
Finder _inGroup(String tooltip) => find.ancestor(
|
||||
of: find.byTooltip(tooltip),
|
||||
matching: find.byType(TonalButtonGroup),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('a bar with three buttons groups two beside the third', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBar(tester, primary: 'Add', icons: ['Sync', 'Sort']);
|
||||
|
||||
expect(_barButtons(tester), ['Add', 'Sync', 'Sort']);
|
||||
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
|
||||
expect(_inGroup('Add'), findsOneWidget);
|
||||
expect(_inGroup('Sync'), findsOneWidget);
|
||||
expect(_inGroup('Sort'), findsNothing);
|
||||
|
||||
await _pumpBar(tester, hasSearch: true, menuItems: ['Settings']);
|
||||
|
||||
expect(_barButtons(tester), ['Search', 'More']);
|
||||
});
|
||||
|
||||
testWidgets('the menu stands apart from the grouped buttons', (tester) async {
|
||||
await _pumpBar(
|
||||
tester,
|
||||
hasSearch: true,
|
||||
primary: 'Delay test',
|
||||
menuItems: ['Settings'],
|
||||
);
|
||||
|
||||
expect(_barButtons(tester), ['Search', 'Delay test', 'More']);
|
||||
expect(_inGroup('Search'), findsOneWidget);
|
||||
expect(_inGroup('Delay test'), findsOneWidget);
|
||||
expect(_inGroup('More'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('past three a bar without search keeps two actions', (
|
||||
tester,
|
||||
) async {
|
||||
final taps = await _pumpBar(
|
||||
tester,
|
||||
primary: 'Add',
|
||||
icons: ['Sync', 'Sort', 'Filter'],
|
||||
);
|
||||
|
||||
expect(_barButtons(tester), ['Add', 'Sync', 'More']);
|
||||
|
||||
await _openMenu(tester);
|
||||
expect(_menuLabels(tester, ['Add', 'Sync', 'Sort', 'Filter']), [
|
||||
'Sort',
|
||||
'Filter',
|
||||
]);
|
||||
|
||||
await tester.tap(find.text('Filter'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, ['Filter']);
|
||||
});
|
||||
|
||||
testWidgets('past three a searchable bar keeps its primary action', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpBar(
|
||||
tester,
|
||||
hasSearch: true,
|
||||
primary: 'Delay test',
|
||||
icons: ['Locate'],
|
||||
menuItems: ['Settings'],
|
||||
);
|
||||
|
||||
expect(_barButtons(tester), ['Search', 'Delay test', 'More']);
|
||||
|
||||
await _openMenu(tester);
|
||||
expect(_menuLabels(tester, ['Delay test', 'Locate', 'Settings']), [
|
||||
'Locate',
|
||||
'Settings',
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('a bar that folds its primary action keeps icons in sight', (
|
||||
tester,
|
||||
) async {
|
||||
final taps = await _pumpBar(
|
||||
tester,
|
||||
primary: 'Add',
|
||||
primaryLoading: true,
|
||||
foldPrimary: true,
|
||||
icons: ['Update', 'Sort', 'Filter'],
|
||||
);
|
||||
|
||||
expect(_barButtons(tester), ['Update', 'Sort', 'More']);
|
||||
|
||||
await _openMenu(tester);
|
||||
expect(_menuLabels(tester, ['Add', 'Update', 'Sort', 'Filter']), [
|
||||
'Add',
|
||||
'Filter',
|
||||
]);
|
||||
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(taps, isEmpty, reason: 'a running action folds in disabled');
|
||||
});
|
||||
|
||||
testWidgets('a selection keeps its actions grouped beside the search', (
|
||||
tester,
|
||||
) async {
|
||||
final taps = await _pumpBar(
|
||||
tester,
|
||||
hasSearch: true,
|
||||
selection: ['Delete', 'Select all'],
|
||||
);
|
||||
|
||||
expect(_barButtons(tester), ['Search', 'Delete', 'Select all']);
|
||||
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byTooltip('Search'),
|
||||
matching: find.byType(TonalButtonGroup),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byTooltip('Delete'),
|
||||
matching: find.byType(TonalButtonGroup),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(_inBar(find.byTooltip('Search')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
_barButtons(tester),
|
||||
containsAllInOrder(['Clear search', 'Delete', 'Select all']),
|
||||
);
|
||||
expect(_inBar(find.byGlyph(AppGlyphs.more)), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Select all'));
|
||||
expect(taps, ['Select all']);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/theme.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/views/dashboard/widget_registry.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
@@ -14,6 +15,8 @@ import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('back layers are consumed from inner to outer', (tester) async {
|
||||
var innerActive = true;
|
||||
@@ -200,7 +203,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
@@ -237,7 +240,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
@@ -304,9 +307,7 @@ void main() {
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_vert));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
@@ -357,7 +358,7 @@ void main() {
|
||||
const DashboardState(
|
||||
dashboardWidgets: [
|
||||
DashboardWidget.networkSpeed,
|
||||
DashboardWidget.outboundModeV2,
|
||||
DashboardWidget.outboundMode,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -384,23 +385,22 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
final deleteButton = find.ancestor(
|
||||
of: find.byIcon(Icons.close).first,
|
||||
of: find.byGlyph(AppGlyphs.close).first,
|
||||
matching: find.byType(IconButton),
|
||||
);
|
||||
tester.widget<IconButton>(deleteButton).onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 301));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.state<SuperGridState>(find.byType(SuperGrid)).snapshotChildren,
|
||||
[DashboardWidget.outboundModeV2.widget],
|
||||
);
|
||||
expect(tester.state<SuperGridState>(find.byType(SuperGrid)).items, [
|
||||
DashboardWidget.outboundMode.widget,
|
||||
]);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.byKey(const ValueKey('edit-icon')), findsOneWidget);
|
||||
expect(container.read(appSettingProvider).dashboardWidgets, [
|
||||
DashboardWidget.outboundModeV2,
|
||||
DashboardWidget.outboundMode,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Future<void> _sendFind(WidgetTester tester) async {
|
||||
final modifier = controlSingleActivator(LogicalKeyboardKey.keyF).meta
|
||||
? LogicalKeyboardKey.metaLeft
|
||||
: LogicalKeyboardKey.controlLeft;
|
||||
await tester.sendKeyDownEvent(modifier);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
|
||||
await tester.sendKeyUpEvent(modifier);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Widget _page({
|
||||
ValueChanged<String>? onSearch,
|
||||
List<Widget> actions = const [],
|
||||
List<IconButtonData> searchActions = const [],
|
||||
}) {
|
||||
return CommonScaffold(
|
||||
title: 'Page',
|
||||
searchState: onSearch == null
|
||||
? null
|
||||
: AppBarSearchState(onSearch: onSearch),
|
||||
actions: actions,
|
||||
searchActions: searchActions,
|
||||
body: const SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('the find shortcut opens search and refocuses the field', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(wrapInProviderScope: true, child: _page(onSearch: (_) {})),
|
||||
);
|
||||
|
||||
await _sendFind(tester);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'needle');
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pump();
|
||||
|
||||
await _sendFind(tester);
|
||||
final field = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(field.focusNode!.hasFocus, isTrue);
|
||||
expect(
|
||||
field.controller!.selection,
|
||||
const TextSelection(baseOffset: 0, extentOffset: 6),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the find shortcut leaves a page without search alone', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(TestApp(wrapInProviderScope: true, child: _page()));
|
||||
|
||||
await _sendFind(tester);
|
||||
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the find shortcut skips a page under another route', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(wrapInProviderScope: true, child: _page(onSearch: (_) {})),
|
||||
);
|
||||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const Scaffold(body: Text('covering route')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await _sendFind(tester);
|
||||
expect(find.byType(TextField, skipOffstage: false), findsNothing);
|
||||
|
||||
navigator.pop();
|
||||
await tester.pumpAndSettle();
|
||||
await _sendFind(tester);
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the find shortcut skips an inactive page', (tester) async {
|
||||
final searches = <String>[];
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: PageActivityScope(
|
||||
isActive: false,
|
||||
child: _page(onSearch: searches.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await _sendFind(tester);
|
||||
|
||||
expect(searches, isEmpty);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('search actions stay beside the field while actions hide', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: _page(
|
||||
onSearch: (_) {},
|
||||
actions: const [Text('page action')],
|
||||
searchActions: [
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.add,
|
||||
onPressed: () {},
|
||||
tooltip: 'search action',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('page action'), findsOneWidget);
|
||||
expect(find.byTooltip('search action'), findsNothing);
|
||||
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('page action'), findsNothing);
|
||||
expect(find.byTooltip('search action'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -6,12 +6,14 @@ class _Host extends StatefulWidget {
|
||||
final List<int> data;
|
||||
final bool enable;
|
||||
final VoidCallback onCancelToEnd;
|
||||
final VoidCallback onResumeToEnd;
|
||||
final ScrollController controller;
|
||||
|
||||
const _Host({
|
||||
required this.data,
|
||||
required this.enable,
|
||||
required this.onCancelToEnd,
|
||||
required this.onResumeToEnd,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@@ -29,6 +31,7 @@ class _HostState extends State<_Host> {
|
||||
dataSource: widget.data,
|
||||
enable: widget.enable,
|
||||
onCancelToEnd: widget.onCancelToEnd,
|
||||
onResumeToEnd: widget.onResumeToEnd,
|
||||
child: ListView.builder(
|
||||
controller: widget.controller,
|
||||
itemCount: widget.data.length,
|
||||
@@ -44,10 +47,12 @@ class _HostState extends State<_Host> {
|
||||
void main() {
|
||||
late ScrollController controller;
|
||||
late int cancelCount;
|
||||
late int resumeCount;
|
||||
|
||||
setUp(() {
|
||||
controller = ScrollController();
|
||||
cancelCount = 0;
|
||||
resumeCount = 0;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -65,6 +70,7 @@ void main() {
|
||||
enable: enable,
|
||||
controller: controller,
|
||||
onCancelToEnd: () => cancelCount++,
|
||||
onResumeToEnd: () => resumeCount++,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -107,6 +113,81 @@ void main() {
|
||||
expect(cancelCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('resumes when the user scrolls back to the end', (tester) async {
|
||||
await pump(tester, data: List.generate(20, (i) => i));
|
||||
await pump(tester, data: List.generate(21, (i) => i));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 200));
|
||||
await tester.pumpAndSettle();
|
||||
expect(cancelCount, 1);
|
||||
|
||||
await pump(tester, data: List.generate(21, (i) => i), enable: false);
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
expect(resumeCount, 0);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -400));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.offset, controller.position.maxScrollExtent);
|
||||
expect(resumeCount, 1);
|
||||
});
|
||||
|
||||
// The viewport is 600 tall and a row is 100, so six rows fill a screen.
|
||||
Future<void> pumpAtEnd(WidgetTester tester) async {
|
||||
await pump(tester, data: List.generate(20, (i) => i));
|
||||
await pump(tester, data: List.generate(21, (i) => i));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.offset, controller.position.maxScrollExtent);
|
||||
}
|
||||
|
||||
testWidgets('a batch shorter than a screen animates to the end', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpAtEnd(tester);
|
||||
|
||||
await pump(tester, data: List.generate(25, (i) => i));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(controller.offset, lessThan(controller.position.maxScrollExtent));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.offset, controller.position.maxScrollExtent);
|
||||
});
|
||||
|
||||
testWidgets('a batch past a screenful jumps instead, with nothing left to '
|
||||
'carry through an animation', (tester) async {
|
||||
await pumpAtEnd(tester);
|
||||
|
||||
await pump(tester, data: List.generate(41, (i) => i));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.offset, controller.position.maxScrollExtent);
|
||||
});
|
||||
|
||||
testWidgets('a viewport that shrinks keeps a following list at the end', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(800, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await pumpAtEnd(tester);
|
||||
|
||||
tester.view.physicalSize = const Size(800, 400);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.offset, controller.position.maxScrollExtent);
|
||||
|
||||
await pump(tester, data: List.generate(21, (i) => i), enable: false);
|
||||
tester.view.physicalSize = const Size(800, 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.offset, lessThan(controller.position.maxScrollExtent));
|
||||
});
|
||||
|
||||
testWidgets('re-enabling scrolls back to the end', (tester) async {
|
||||
final data = List.generate(20, (i) => i);
|
||||
await pump(tester, data: data, enable: false);
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/scroll.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet_header.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Widget _list() {
|
||||
Widget _list({ScrollController? controller}) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: 100,
|
||||
itemBuilder: (_, index) => SizedBox(height: 40, child: Text('$index')),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sheet(Widget body) {
|
||||
return TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: CommonScaffold(title: 'title', body: body),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final _desktop = TargetPlatformVariant.only(TargetPlatform.macOS);
|
||||
final _everyPlatform = TargetPlatformVariant(const {
|
||||
const _everyPlatform = TargetPlatformVariant({
|
||||
TargetPlatform.macOS,
|
||||
TargetPlatform.android,
|
||||
});
|
||||
@@ -57,21 +71,10 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'a transparent tool bar keeps the scroll bar below the header',
|
||||
'a bottom sheet keeps the scroll bar below its floating header',
|
||||
variant: _everyPlatform,
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: AdaptiveSheetScaffold(
|
||||
title: 'title',
|
||||
sheetTransparentToolBar: true,
|
||||
body: _list(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(_sheet(_list()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollBar = tester.widget<CommonScrollBar>(
|
||||
@@ -82,23 +85,170 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'an opaque tool bar shows the scroll bar without an inset',
|
||||
'a floating scroll bar is the only bar its list gets',
|
||||
variant: _everyPlatform,
|
||||
(tester) async {
|
||||
final controller = ScrollController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
child: SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: AdaptiveSheetScaffold(title: 'title', body: _list()),
|
||||
_sheet(
|
||||
FloatingScrollbar(
|
||||
controller: controller,
|
||||
hintBuilder: (_) => 'hint',
|
||||
child: _list(controller: controller),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CommonScrollBar), findsOneWidget);
|
||||
final scrollBar = tester.widget<CommonScrollBar>(
|
||||
find.byType(CommonScrollBar),
|
||||
);
|
||||
expect(scrollBar.padding, EdgeInsets.zero);
|
||||
expect(scrollBar.padding, const EdgeInsets.only(top: sheetAppBarHeight));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a sheet header lets what scrolls under it read through', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_sheet(_list()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cover = tester.widget<DecoratedBox>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.ancestor(
|
||||
of: find.byType(SheetToolBar),
|
||||
matching: find.byType(FloatingHeader),
|
||||
),
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final gradient = (cover.decoration as BoxDecoration).gradient;
|
||||
expect(gradient, isA<LinearGradient>());
|
||||
expect(gradient!.colors.map((color) => color.a), everyElement(lessThan(1)));
|
||||
});
|
||||
|
||||
for (final type in [null, SheetType.page, SheetType.sideSheet]) {
|
||||
testWidgets(
|
||||
'${type?.name ?? 'a page'} floats its bar over the list under it',
|
||||
variant: _everyPlatform,
|
||||
(tester) async {
|
||||
final scaffold = CommonScaffold(title: 'title', body: _list());
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: type == null
|
||||
? scaffold
|
||||
: SheetProvider(type: type, child: scaffold),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<AppBar>(find.byType(AppBar));
|
||||
expect(appBar.forceMaterialTransparency, isTrue);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byType(FloatingHeader),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
|
||||
expect(tester.getRect(find.text('0')).top, barBottom);
|
||||
|
||||
await tester.drag(find.byType(Scrollable), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getRect(find.text('2')).top, lessThan(barBottom));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('a page with a title widget floats its bar as a titled one', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: CommonScaffold(titleWidget: const TextField(), body: _list()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byType(FloatingHeader),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
|
||||
expect(tester.getRect(find.text('0')).top, barBottom);
|
||||
|
||||
await tester.drag(find.text('0'), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getRect(find.text('2')).top, lessThan(barBottom));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keyboard focus moving up clears the floating bar',
|
||||
variant: _everyPlatform,
|
||||
(tester) async {
|
||||
FocusManager.instance.highlightStrategy =
|
||||
FocusHighlightStrategy.alwaysTraditional;
|
||||
addTearDown(
|
||||
() => FocusManager.instance.highlightStrategy =
|
||||
FocusHighlightStrategy.automatic,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: CommonScaffold(
|
||||
title: 'title',
|
||||
body: ListView.builder(
|
||||
itemCount: 100,
|
||||
itemBuilder: (_, index) =>
|
||||
Focus(child: SizedBox(height: 40, child: Text('$index'))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(Scrollable), const Offset(0, -410));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final barBottom = tester.getRect(find.byType(AppBar)).bottom;
|
||||
var below = 0;
|
||||
while (find.text('$below').evaluate().isEmpty ||
|
||||
tester.getRect(find.text('$below')).top < barBottom) {
|
||||
below++;
|
||||
}
|
||||
final above = find.text('${below - 1}');
|
||||
expect(tester.getRect(above).top, lessThan(barBottom));
|
||||
|
||||
Focus.of(tester.element(find.text('$below'))).requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
FocusManager.instance.primaryFocus,
|
||||
Focus.of(tester.element(above)),
|
||||
);
|
||||
expect(tester.getRect(above).top, moreOrLessEquals(barBottom));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Future<void> _pumpStackedSheets(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final container = ProviderContainer(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
globalState.container = container;
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: TestApp(
|
||||
child: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () {
|
||||
showSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () {
|
||||
showSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => const Text('inner'),
|
||||
);
|
||||
},
|
||||
child: const Text('open inner'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('open outer'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color? _barrierColorAround(WidgetTester tester, String text) {
|
||||
return ModalRoute.of(tester.element(find.text(text)))!.barrierColor;
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final (kind, size) in const [
|
||||
('side sheet', Size(1000, 800)),
|
||||
('bottom sheet', Size(400, 800)),
|
||||
]) {
|
||||
testWidgets('a $kind stacked on a sheet brings its own scrim', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpStackedSheets(tester, size);
|
||||
|
||||
await tester.tap(find.text('open outer'));
|
||||
await tester.pumpAndSettle();
|
||||
final scheme = tester.element(find.text('open inner')).colorScheme;
|
||||
final scrim = scheme.scrim.withValues(alpha: 0.32);
|
||||
expect(_barrierColorAround(tester, 'open inner'), scrim);
|
||||
|
||||
await tester.tap(find.text('open inner'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_barrierColorAround(tester, 'inner'), scrim);
|
||||
expect(find.byType(BackdropFilter), findsNothing);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet_navigator.dart';
|
||||
import 'package:fl_clash/widgets/side_sheet.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
final hostKey = GlobalKey<NavigatorState>();
|
||||
late BuildContext pageContext;
|
||||
|
||||
Widget pages(Widget child) => SheetPagesNavigator(
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
MaterialPageRoute<void>(builder: (_) => child),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> pumpNested(WidgetTester tester, {bool isMobileView = true}) {
|
||||
return tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [isMobileViewProvider.overrideWithValue(isMobileView)],
|
||||
child: MaterialApp(
|
||||
home: Navigator(
|
||||
key: hostKey,
|
||||
onGenerateInitialRoutes: (_, _) => [
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => pages(
|
||||
pages(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
pageContext = context;
|
||||
return const SizedBox.expand();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('skips every sheet navigator up to the one hosting them', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpNested(tester);
|
||||
expect(Navigator.of(pageContext).widget, isA<SheetPagesNavigator>());
|
||||
expect(sheetNavigatorOf(pageContext), hostKey.currentState);
|
||||
});
|
||||
|
||||
testWidgets('a side sheet opened from a sheet page goes over the sheet', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpNested(tester);
|
||||
unawaited(
|
||||
showModalSideSheet<void>(
|
||||
context: pageContext,
|
||||
builder: (_) => const Text('side'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
Navigator.of(tester.element(find.text('side'))),
|
||||
hostKey.currentState,
|
||||
);
|
||||
});
|
||||
|
||||
for (final isMobileView in [true, false]) {
|
||||
testWidgets(
|
||||
'a sheet opened from a sheet page closes itself, not the page, through '
|
||||
'the context its builder gets${isMobileView ? '' : ' on desktop'}',
|
||||
(tester) async {
|
||||
await pumpNested(tester, isMobileView: isMobileView);
|
||||
String? picked;
|
||||
unawaited(
|
||||
showSheet<String>(
|
||||
context: pageContext,
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('picked'),
|
||||
child: const Text('pick'),
|
||||
),
|
||||
).then((value) => picked = value),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('pick'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(picked, 'picked');
|
||||
expect(find.text('pick'), findsNothing);
|
||||
expect(pageContext.mounted, isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/search_field.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
late List<String> queries;
|
||||
|
||||
setUp(() => queries = []);
|
||||
|
||||
Widget scaffold({required SheetType? type, Widget? body}) {
|
||||
final scaffold = CommonScaffold(
|
||||
title: 'title',
|
||||
searchState: AppBarSearchState(onSearch: queries.add),
|
||||
body: body ?? const SizedBox(),
|
||||
);
|
||||
return TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: type == null
|
||||
? scaffold
|
||||
: SheetProvider(type: type, child: scaffold),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('a bottom sheet searches from a docked field, not a bar button', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(scaffold(type: SheetType.bottomSheet));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SearchField), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byGlyph(AppGlyphs.search),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('title'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'wiki');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(queries, ['wiki']);
|
||||
// The title survives searching, because nothing took the bar over.
|
||||
expect(find.text('title'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a page and a side sheet keep the bar button', (tester) async {
|
||||
for (final type in [null, SheetType.page, SheetType.sideSheet]) {
|
||||
await tester.pumpWidget(scaffold(type: type));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SearchField), findsNothing, reason: '$type');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byGlyph(AppGlyphs.search),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: '$type',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> openModalSheet(WidgetTester tester, {Widget? body}) async {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => SheetProvider(
|
||||
type: SheetType.bottomSheet,
|
||||
child: CommonScaffold(
|
||||
title: 'title',
|
||||
searchState: AppBarSearchState(onSearch: queries.add),
|
||||
body: body ?? const SizedBox(height: 200),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a docked field leaves the list room to scroll clear of it', (
|
||||
tester,
|
||||
) async {
|
||||
late double inset;
|
||||
|
||||
await openModalSheet(
|
||||
tester,
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
inset = BottomInsetScope.of(context);
|
||||
return const SizedBox(height: 200);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(inset, BottomInsetScope.dockedSearchInset);
|
||||
expect(
|
||||
tester.getSize(find.byType(SearchField)).height,
|
||||
BottomInsetScope.dockedSearchHeight,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a docked field carries its own opaque surface', (tester) async {
|
||||
await openModalSheet(tester);
|
||||
|
||||
final decoration = tester
|
||||
.widget<TextField>(find.byType(TextField))
|
||||
.decoration!;
|
||||
final theme = Theme.of(tester.element(find.byType(TextField)));
|
||||
final fill = decoration.fillColor ?? theme.inputDecorationTheme.fillColor;
|
||||
|
||||
expect(decoration.filled ?? theme.inputDecorationTheme.filled, isTrue);
|
||||
expect(fill, theme.colorScheme.surfaceContainerHigh);
|
||||
expect(fill!.a, 1);
|
||||
// The dock itself paints nothing, so no band spans the sheet behind it.
|
||||
final dock = tester.widget<Material>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(SearchField),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(dock.type, MaterialType.transparency);
|
||||
});
|
||||
|
||||
testWidgets('a docked field is a pill', (tester) async {
|
||||
await openModalSheet(tester);
|
||||
|
||||
final theme = Theme.of(tester.element(find.byType(TextField)));
|
||||
final border = theme.inputDecorationTheme.border;
|
||||
|
||||
expect(border, isA<AppInputBorder>());
|
||||
expect((border! as AppInputBorder).borderRadius, AppRadius.full);
|
||||
});
|
||||
|
||||
testWidgets('a docked field clears the bottom safe area once', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = const FakeViewPadding(bottom: 34);
|
||||
tester.view.viewPadding = const FakeViewPadding(bottom: 34);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await openModalSheet(tester);
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(SearchField)).dy,
|
||||
900 - 34 - BottomInsetScope.dockedSearchMargin,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a docked field stays above the keyboard', (tester) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await openModalSheet(tester);
|
||||
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(900));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(600));
|
||||
});
|
||||
|
||||
testWidgets('a snap sheet keeps its docked field above the keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showSnapSheet(
|
||||
context,
|
||||
builder: (_, controller) => CommonScaffold(
|
||||
title: 'title',
|
||||
searchState: AppBarSearchState(onSearch: queries.add),
|
||||
body: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: 60,
|
||||
itemBuilder: (_, index) =>
|
||||
SizedBox(height: 40, child: Text('$index')),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SearchField), findsOneWidget);
|
||||
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(900));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getBottomLeft(find.byType(SearchField)).dy, lessThan(600));
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/sidebar.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
const _destinations = [
|
||||
SidebarDestination(glyph: AppGlyphs.dashboard, label: 'Dashboard'),
|
||||
SidebarDestination(glyph: AppGlyphs.proxies, label: 'Proxies'),
|
||||
SidebarDestination(glyph: AppGlyphs.profiles, label: 'Profiles'),
|
||||
];
|
||||
|
||||
Finder _glyph(Glyph glyph) => find.byWidgetPredicate(
|
||||
(widget) => widget is AnimatedGlyph && widget.glyph == glyph,
|
||||
);
|
||||
|
||||
double _fillOf(WidgetTester tester, Glyph glyph) {
|
||||
final paint = tester.widget<CustomPaint>(
|
||||
find.descendant(of: _glyph(glyph), matching: find.byType(CustomPaint)),
|
||||
);
|
||||
return (paint.painter! as GlyphPainter).fill;
|
||||
}
|
||||
|
||||
Widget _sidebar({
|
||||
List<SidebarDestination> destinations = _destinations,
|
||||
int selectedIndex = 0,
|
||||
bool expanded = true,
|
||||
ValueChanged<int>? onSelected,
|
||||
VoidCallback? onToggle,
|
||||
Size windowControls = Size.zero,
|
||||
EdgeInsets safePadding = EdgeInsets.zero,
|
||||
}) {
|
||||
return TestApp(
|
||||
child: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(padding: safePadding),
|
||||
child: NavigationSidebar(
|
||||
destinations: destinations,
|
||||
selectedIndex: selectedIndex,
|
||||
expanded: expanded,
|
||||
onSelected: onSelected ?? (_) {},
|
||||
onToggle: onToggle,
|
||||
windowControls: windowControls,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.expand(key: ValueKey('content'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Finder get _sidebarFinder => find.byType(NavigationSidebar);
|
||||
|
||||
Finder _indicator(WidgetTester tester) {
|
||||
final primary = Theme.of(tester.element(_sidebarFinder)).colorScheme.primary;
|
||||
return find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is DecoratedBox &&
|
||||
widget.decoration is ShapeDecoration &&
|
||||
(widget.decoration as ShapeDecoration).color == primary,
|
||||
);
|
||||
}
|
||||
|
||||
Finder _labels(String label) => find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Opacity && widget.alwaysIncludeSemantics,
|
||||
),
|
||||
);
|
||||
|
||||
Iterable<double> _labelOpacities(WidgetTester tester, String label) {
|
||||
return tester.widgetList<Opacity>(_labels(label)).map((o) => o.opacity);
|
||||
}
|
||||
|
||||
Finder _shown(String label) => find.descendant(
|
||||
of: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Opacity &&
|
||||
widget.alwaysIncludeSemantics &&
|
||||
widget.opacity == 1,
|
||||
),
|
||||
matching: find.text(label),
|
||||
);
|
||||
|
||||
Finder _rowTooltip(String message) => find.byWidgetPredicate(
|
||||
(widget) => widget is Tooltip && widget.message == message,
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('the selected row carries a centered indicator', (tester) async {
|
||||
await tester.pumpWidget(_sidebar(selectedIndex: 1));
|
||||
|
||||
final indicator = tester.getRect(_indicator(tester));
|
||||
expect(indicator.width, 3);
|
||||
expect(indicator.height, 16);
|
||||
expect(
|
||||
indicator.center.dy,
|
||||
moreOrLessEquals(tester.getCenter(_glyph(AppGlyphs.proxies)).dy),
|
||||
);
|
||||
expect(tester.getSize(_sidebarFinder).width, 220);
|
||||
expect(_labelOpacities(tester, 'Proxies'), [1]);
|
||||
expect(
|
||||
tester.getSemantics(find.text('Proxies')),
|
||||
isSemantics(isSelected: true, isButton: true),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the indicator stretches toward the new row and settles on it', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_sidebar());
|
||||
final start = tester.getRect(_indicator(tester));
|
||||
|
||||
await tester.pumpWidget(_sidebar(selectedIndex: 2));
|
||||
await tester.pump(const Duration(milliseconds: 160));
|
||||
final middle = tester.getRect(_indicator(tester));
|
||||
expect(middle.top, greaterThanOrEqualTo(start.top));
|
||||
expect(middle.height, greaterThan(16));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
final end = tester.getRect(_indicator(tester));
|
||||
expect(end.height, 16);
|
||||
expect(
|
||||
end.center.dy,
|
||||
moreOrLessEquals(tester.getCenter(_glyph(AppGlyphs.profiles)).dy),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('only the selected row fills its icon', (tester) async {
|
||||
await tester.pumpWidget(_sidebar());
|
||||
expect(_fillOf(tester, AppGlyphs.dashboard), 1);
|
||||
expect(_fillOf(tester, AppGlyphs.profiles), 0);
|
||||
|
||||
await tester.pumpWidget(_sidebar(selectedIndex: 2));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fillOf(tester, AppGlyphs.dashboard), 0);
|
||||
expect(_fillOf(tester, AppGlyphs.profiles), 1);
|
||||
});
|
||||
|
||||
testWidgets('the toggle widens the sidebar in place when it can', (
|
||||
tester,
|
||||
) async {
|
||||
var toggled = 0;
|
||||
int? selected;
|
||||
await tester.pumpWidget(
|
||||
_sidebar(
|
||||
expanded: false,
|
||||
onToggle: () => toggled++,
|
||||
onSelected: (index) => selected = index,
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.getSize(_sidebarFinder).width, 48);
|
||||
expect(_labelOpacities(tester, 'Proxies'), [0]);
|
||||
expect(_rowTooltip('Proxies'), findsOneWidget);
|
||||
await tester.tap(_glyph(AppGlyphs.profiles));
|
||||
expect(selected, 2);
|
||||
|
||||
await tester.tap(find.byTooltip('Expand'));
|
||||
await tester.pump();
|
||||
expect(toggled, 1);
|
||||
expect(find.text('Proxies'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(_sidebar(onToggle: () {}));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
final width = tester.getSize(_sidebarFinder).width;
|
||||
expect(width, allOf(greaterThan(48), lessThan(220)));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(_sidebarFinder).width, 220);
|
||||
expect(_rowTooltip('Proxies'), findsNothing);
|
||||
expect(find.byTooltip('Collapse'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an expanded row whose label is cut off keeps its tooltip', (
|
||||
tester,
|
||||
) async {
|
||||
const long = 'Подключения и активные соединения';
|
||||
await tester.pumpWidget(
|
||||
_sidebar(
|
||||
destinations: const [
|
||||
..._destinations,
|
||||
SidebarDestination(glyph: AppGlyphs.connections, label: long),
|
||||
],
|
||||
onToggle: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(_rowTooltip(long), findsOneWidget);
|
||||
expect(_rowTooltip('Proxies'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the toggle glyph follows the pane as it widens', (tester) async {
|
||||
List<Rect> glyph(String tooltip) => [
|
||||
for (final shape
|
||||
in tester
|
||||
.widget<GlyphIcon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip(tooltip),
|
||||
matching: find.byType(GlyphIcon),
|
||||
),
|
||||
)
|
||||
.glyph
|
||||
.shapes)
|
||||
shape.path.getBounds(),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(_sidebar(expanded: false, onToggle: () {}));
|
||||
final collapsed = glyph('Expand');
|
||||
|
||||
await tester.pumpWidget(_sidebar(onToggle: () {}));
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
final midway = glyph('Collapse');
|
||||
expect(midway, isNot(collapsed));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(glyph('Collapse'), isNot(midway));
|
||||
});
|
||||
|
||||
testWidgets('window controls keep their corner clear', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_sidebar(
|
||||
expanded: false,
|
||||
onToggle: () {},
|
||||
windowControls: const Size(78, 32),
|
||||
),
|
||||
);
|
||||
|
||||
final sidebar = tester.getRect(_sidebarFinder);
|
||||
expect(sidebar.width, 78);
|
||||
final toggle = find.byTooltip('Expand');
|
||||
expect(
|
||||
tester.getTopLeft(toggle).dy,
|
||||
greaterThanOrEqualTo(sidebar.top + 32),
|
||||
);
|
||||
expect(tester.getCenter(toggle).dx, sidebar.center.dx);
|
||||
expect(tester.getCenter(_glyph(AppGlyphs.profiles)).dx, sidebar.center.dx);
|
||||
final selectedItem = tester.getRect(
|
||||
find
|
||||
.ancestor(
|
||||
of: _glyph(AppGlyphs.dashboard),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(selectedItem.size, const Size(54, 40));
|
||||
expect(selectedItem.center.dx, sidebar.center.dx);
|
||||
|
||||
final folder = tester.getCenter(_glyph(AppGlyphs.profiles));
|
||||
await tester.pumpWidget(
|
||||
_sidebar(onToggle: () {}, windowControls: const Size(78, 32)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(_sidebarFinder).width, 220);
|
||||
expect(tester.getCenter(_glyph(AppGlyphs.profiles)), folder);
|
||||
});
|
||||
|
||||
group('without room to widen', () {
|
||||
Future<void> openOverlay(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Expand'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Proxies'), findsNWidgets(2));
|
||||
}
|
||||
|
||||
testWidgets('the toggle opens the pane over the content', (tester) async {
|
||||
await tester.pumpWidget(_sidebar(expanded: false));
|
||||
final contentWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('content')))
|
||||
.width;
|
||||
|
||||
await openOverlay(tester);
|
||||
|
||||
expect(tester.getSize(_sidebarFinder).width, 48);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('content'))).width,
|
||||
contentWidth,
|
||||
);
|
||||
expect(_labelOpacities(tester, 'Proxies'), unorderedEquals([0, 1]));
|
||||
expect(
|
||||
tester.getTopLeft(find.byTooltip('Collapse')),
|
||||
tester.getTopLeft(find.byTooltip('Expand')),
|
||||
);
|
||||
final paneEdge = tester.widget<Material>(
|
||||
find
|
||||
.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Material && widget.shape is BorderDirectional,
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
(paneEdge.shape! as BorderDirectional).end.color,
|
||||
Theme.of(tester.element(_sidebarFinder)).colorScheme.outlineVariant,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Collapse'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Proxies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the pane covers the safe area around the sidebar', (
|
||||
tester,
|
||||
) async {
|
||||
const safePadding = EdgeInsets.only(left: 40, top: 24, bottom: 16);
|
||||
await tester.pumpWidget(
|
||||
_sidebar(expanded: false, safePadding: safePadding),
|
||||
);
|
||||
final sidebar = tester.getRect(_sidebarFinder);
|
||||
expect(sidebar.width, 48 + safePadding.left);
|
||||
final toggle = tester.getTopLeft(find.byTooltip('Expand'));
|
||||
expect(toggle.dx, greaterThanOrEqualTo(sidebar.left + safePadding.left));
|
||||
expect(toggle.dy, greaterThanOrEqualTo(sidebar.top + safePadding.top));
|
||||
|
||||
await openOverlay(tester);
|
||||
|
||||
final pane = tester.getRect(
|
||||
find
|
||||
.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Material && widget.shape is BorderDirectional,
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(pane.topLeft, sidebar.topLeft);
|
||||
expect(pane.height, sidebar.height);
|
||||
expect(pane.width, 220 + safePadding.left);
|
||||
expect(tester.getTopLeft(find.byTooltip('Collapse')), toggle);
|
||||
});
|
||||
|
||||
testWidgets('choosing a row selects it and closes the pane', (
|
||||
tester,
|
||||
) async {
|
||||
int? selected;
|
||||
await tester.pumpWidget(
|
||||
_sidebar(expanded: false, onSelected: (index) => selected = index),
|
||||
);
|
||||
await openOverlay(tester);
|
||||
|
||||
await tester.tap(_shown('Profiles'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected, 2);
|
||||
expect(find.text('Profiles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a tap outside or escape dismisses the pane', (tester) async {
|
||||
await tester.pumpWidget(_sidebar(expanded: false));
|
||||
|
||||
await openOverlay(tester);
|
||||
await tester.tapAt(const Offset(600, 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Proxies'), findsOneWidget);
|
||||
|
||||
await openOverlay(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Proxies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a tooltip inside the pane lays out over it', (tester) async {
|
||||
await tester.pumpWidget(_sidebar(expanded: false));
|
||||
await openOverlay(tester);
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.addPointer(
|
||||
location: tester.getCenter(find.byTooltip('Collapse')),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(find.text('Collapse'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('widening the window closes the pane', (tester) async {
|
||||
await tester.pumpWidget(_sidebar(expanded: false));
|
||||
await openOverlay(tester);
|
||||
|
||||
await tester.pumpWidget(_sidebar(expanded: false, onToggle: () {}));
|
||||
await tester.pump();
|
||||
expect(find.text('Proxies'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/scaffold.dart';
|
||||
import 'package:fl_clash/widgets/sheet.dart';
|
||||
import 'package:fl_clash/widgets/sheet_header.dart';
|
||||
import 'package:fl_clash/widgets/side_sheet.dart';
|
||||
import 'package:fl_clash/widgets/snap_sheet.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
const _mobile = Size(400, 900);
|
||||
const _desktop = Size(1200, 900);
|
||||
|
||||
Widget _sheet(BuildContext context, ScrollController? controller) {
|
||||
return CommonScaffold(
|
||||
title: 'title',
|
||||
body: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: 60,
|
||||
// Past the floating header, so it can be tapped.
|
||||
itemBuilder: (context, index) => index == 3
|
||||
? TextButton(
|
||||
onPressed: () => Navigator.of(context).pop('done'),
|
||||
child: const Text('done'),
|
||||
)
|
||||
: SizedBox(height: 40, child: Text('$index')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
Future<void> setView(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.text('open')),
|
||||
).read(viewSizeProvider.notifier).value = size;
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<Future<Object?>> openSheet(
|
||||
WidgetTester tester, {
|
||||
SnapSheetBuilder builder = _sheet,
|
||||
}) async {
|
||||
tester.view.physicalSize = _mobile;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
late Future<Object?> result;
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
result = showSnapSheet<Object?>(context, builder: builder);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await setView(tester, _mobile);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
return result;
|
||||
}
|
||||
|
||||
double sheetTop(WidgetTester tester) =>
|
||||
tester.getTopLeft(find.byType(SheetDragHandle)).dy;
|
||||
|
||||
double contentOffset(WidgetTester tester) => tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).last)
|
||||
.position
|
||||
.pixels;
|
||||
|
||||
Finder content() => find.byType(Scrollable).last;
|
||||
|
||||
Future<void> close(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
testWidgets('a drag on a collapsed sheet expands it and leaves the content '
|
||||
'where it was', (tester) async {
|
||||
await openSheet(tester);
|
||||
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
expect(sheetTop(tester), closeTo(collapsed, 1));
|
||||
|
||||
await tester.drag(content(), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
expect(contentOffset(tester), 0);
|
||||
|
||||
await tester.drag(content(), const Offset(0, -100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
expect(contentOffset(tester), greaterThan(0));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('the keyboard raises the sheet without rebuilding its content', (
|
||||
tester,
|
||||
) async {
|
||||
var builds = 0;
|
||||
await openSheet(
|
||||
tester,
|
||||
builder: (context, controller) {
|
||||
builds++;
|
||||
return _sheet(context, controller);
|
||||
},
|
||||
);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final shown = builds;
|
||||
|
||||
for (final height in [100.0, 200.0, 300.0]) {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: height);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(builds, shown);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
sheetTop(tester),
|
||||
closeTo(_mobile.height * (1 - snapSheetDetents.last), 1),
|
||||
);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a drag down past half of the collapsed sheet closes it', (
|
||||
tester,
|
||||
) async {
|
||||
final result = await openSheet(tester);
|
||||
var closed = false;
|
||||
unawaited(result.then((_) => closed = true));
|
||||
|
||||
await tester.drag(content(), const Offset(0, 300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isTrue);
|
||||
expect(find.text('title'), findsNothing);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a short drag down springs the collapsed sheet back and a fling '
|
||||
'down closes it', (tester) async {
|
||||
final result = await openSheet(tester);
|
||||
var closed = false;
|
||||
unawaited(result.then((_) => closed = true));
|
||||
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
|
||||
|
||||
await tester.drag(content(), const Offset(0, 80));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isFalse);
|
||||
expect(sheetTop(tester), closeTo(collapsed, 1));
|
||||
|
||||
await tester.fling(content(), const Offset(0, 80), 1500);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isTrue);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a tap while the sheet settles stays off its content', (
|
||||
tester,
|
||||
) async {
|
||||
final result = await openSheet(tester);
|
||||
var closed = false;
|
||||
unawaited(result.then((_) => closed = true));
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 1000);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.tap(find.text('done'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isFalse);
|
||||
|
||||
await tester.tap(find.text('done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isTrue);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('the content takes taps again once the sheet looks still', (
|
||||
tester,
|
||||
) async {
|
||||
final result = await openSheet(tester);
|
||||
var closed = false;
|
||||
unawaited(result.then((_) => closed = true));
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 1000);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.tap(find.text('done'));
|
||||
await tester.pump();
|
||||
|
||||
expect(closed, isTrue);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a short flick up lands on the detent its velocity projects to', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester);
|
||||
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
|
||||
await tester.drag(content(), const Offset(0, -60));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheetTop(tester), closeTo(collapsed, 1));
|
||||
|
||||
await tester.fling(content(), const Offset(0, -60), 800);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a hard fling up settles without overshooting the detent', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 8000);
|
||||
var highest = sheetTop(tester);
|
||||
for (var i = 0; i < 60; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
highest = min(highest, sheetTop(tester));
|
||||
}
|
||||
|
||||
expect(highest, greaterThan(expanded - 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a sideways wheel tick leaves a settling sheet on its way', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pump(const Duration(milliseconds: 30));
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(50, 0)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a collapsed sheet fits its content on screen', (tester) async {
|
||||
await openSheet(tester);
|
||||
|
||||
expect(
|
||||
tester.getBottomLeft(content()).dy,
|
||||
lessThanOrEqualTo(_mobile.height),
|
||||
);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a second drag up while the sheet expands scrolls the content '
|
||||
'at once and the sheet still opens', (tester) async {
|
||||
await openSheet(tester);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 1000);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(sheetTop(tester), greaterThan(expanded + 1));
|
||||
expect(contentOffset(tester), 0);
|
||||
|
||||
await tester.drag(content(), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
expect(contentOffset(tester), closeTo(300 - kDragSlopDefault, 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a drag down while the sheet expands takes it back', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester);
|
||||
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 1000);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.drag(content(), const Offset(0, 150));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(collapsed, 1));
|
||||
expect(contentOffset(tester), 0);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a wheel tick on a collapsed sheet expands it and leaves the '
|
||||
'content where it was', (tester) async {
|
||||
await openSheet(tester);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
final location = tester.getCenter(content());
|
||||
await tester.sendEventToBinding(pointer.hover(location));
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
expect(contentOffset(tester), 0);
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
expect(contentOffset(tester), 50);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a wheel tick on a sheet too short to scroll expands it', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(
|
||||
tester,
|
||||
builder: (_, controller) => CommonScaffold(
|
||||
title: 'title',
|
||||
body: ListView(
|
||||
controller: controller,
|
||||
children: const [SizedBox(height: 40, child: Text('0'))],
|
||||
),
|
||||
),
|
||||
);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a wheel turn that scrolls the content to its top leaves the '
|
||||
'sheet where it is, and the next turn takes it down', (tester) async {
|
||||
await openSheet(tester);
|
||||
final collapsed = _mobile.height * (1 - snapSheetDetents.first);
|
||||
final expanded = _mobile.height * (1 - snapSheetDetents.last);
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(contentOffset(tester), 50);
|
||||
|
||||
for (var tick = 0; tick < 4; tick++) {
|
||||
await tester.sendEventToBinding(
|
||||
pointer.scroll(
|
||||
const Offset(0, -50),
|
||||
timeStamp: Duration(seconds: 1, milliseconds: 16 * tick),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(contentOffset(tester), 0);
|
||||
expect(sheetTop(tester), closeTo(expanded, 1));
|
||||
|
||||
await tester.sendEventToBinding(
|
||||
pointer.scroll(
|
||||
const Offset(0, -50),
|
||||
timeStamp: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), closeTo(collapsed, 1));
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a settle cut short by a resize lands on the detent of the new '
|
||||
'size', (tester) async {
|
||||
await openSheet(tester);
|
||||
await tester.drag(content(), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.fling(content(), const Offset(0, 120), 900);
|
||||
await tester.pump(const Duration(milliseconds: 30));
|
||||
|
||||
const shorter = Size(400, 500);
|
||||
await setView(tester, shorter);
|
||||
|
||||
expect(
|
||||
sheetTop(tester),
|
||||
closeTo(shorter.height * (1 - snapSheetDetents.first), 1),
|
||||
);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('the sheet follows the view across the mobile breakpoint and '
|
||||
'reports the result of the form it closes in', (tester) async {
|
||||
final result = await openSheet(tester);
|
||||
Object? closedWith;
|
||||
var closed = false;
|
||||
unawaited(
|
||||
result.then((value) {
|
||||
closedWith = value;
|
||||
closed = true;
|
||||
}),
|
||||
);
|
||||
expect(find.byType(SheetDragHandle), findsOneWidget);
|
||||
|
||||
await setView(tester, _desktop);
|
||||
|
||||
expect(find.byType(SideSheet), findsOneWidget);
|
||||
expect(find.byType(SheetDragHandle), findsNothing);
|
||||
expect(find.text('title'), findsOneWidget);
|
||||
|
||||
await setView(tester, _mobile);
|
||||
|
||||
expect(find.byType(SideSheet), findsNothing);
|
||||
expect(find.byType(SheetDragHandle), findsOneWidget);
|
||||
|
||||
expect(closed, isFalse);
|
||||
await tester.tap(find.text('done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isTrue);
|
||||
expect(closedWith, 'done');
|
||||
expect(find.text('title'), findsNothing);
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
|
||||
testWidgets('a side sheet on a page navigator the mobile layout drops comes '
|
||||
'back as a bottom sheet', (tester) async {
|
||||
tester.view.physicalSize = _desktop;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
late Future<Object?> result;
|
||||
final page = Builder(
|
||||
key: GlobalKey(),
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
result = showSnapSheet<Object?>(context, builder: _sheet);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) => ref.watch(isMobileViewProvider)
|
||||
? page
|
||||
: Navigator(
|
||||
pages: [MaterialPage<void>(child: page)],
|
||||
onDidRemovePage: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await setView(tester, _desktop);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SideSheet), findsOneWidget);
|
||||
Object? closedWith;
|
||||
var closed = false;
|
||||
unawaited(
|
||||
result.then((value) {
|
||||
closedWith = value;
|
||||
closed = true;
|
||||
}),
|
||||
);
|
||||
|
||||
await setView(tester, _mobile);
|
||||
|
||||
expect(find.byType(SideSheet), findsNothing);
|
||||
expect(find.byType(SheetDragHandle), findsOneWidget);
|
||||
expect(closed, isFalse);
|
||||
|
||||
await tester.tap(find.text('done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isTrue);
|
||||
expect(closedWith, 'done');
|
||||
|
||||
await close(tester);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _labels = <int, String>{0: 'One', 1: 'Two', 2: 'Three'};
|
||||
|
||||
Future<void> _pumpTabBar(
|
||||
WidgetTester tester, {
|
||||
required ValueChanged<int?> onValueChanged,
|
||||
Set<int> disabledChildren = const {},
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusTraversalGroup(
|
||||
policy: PageTraversalPolicy(),
|
||||
child: PageFocusScope(
|
||||
child: Center(
|
||||
child: CommonTabBar<int>(
|
||||
groupValue: 0,
|
||||
disabledChildren: disabledChildren,
|
||||
thumbColor: Colors.blue,
|
||||
backgroundColor: Colors.grey,
|
||||
children: {
|
||||
for (final entry in _labels.entries)
|
||||
entry.key: Text(entry.value),
|
||||
},
|
||||
onValueChanged: onValueChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
String? _focusedLabel() {
|
||||
final context = FocusManager.instance.primaryFocus?.context;
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
final labels = find
|
||||
.descendant(
|
||||
of: find.byWidget(context.widget),
|
||||
matching: find.byType(Text),
|
||||
)
|
||||
.evaluate()
|
||||
.map((element) => (element.widget as Text).data)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
return labels.isEmpty ? null : labels.first;
|
||||
}
|
||||
|
||||
bool _hasFocusRing(WidgetTester tester, String label) {
|
||||
return tester
|
||||
.widgetList<DecoratedBox>(
|
||||
find.ancestor(
|
||||
of: find.text(label).first,
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
)
|
||||
.any((box) {
|
||||
final decoration = box.decoration;
|
||||
return decoration is ShapeDecoration &&
|
||||
decoration.shape is RoundedSuperellipseBorder &&
|
||||
(decoration.shape as RoundedSuperellipseBorder).side.style ==
|
||||
BorderStyle.solid;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _focusFirstSegment(WidgetTester tester) async {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pump();
|
||||
expect(_focusedLabel(), 'One', reason: 'tab should seed focus on a segment');
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('arrow right walks from one segment to the next', (tester) async {
|
||||
await _pumpTabBar(tester, onValueChanged: (_) {});
|
||||
await _focusFirstSegment(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
|
||||
expect(_focusedLabel(), 'Two');
|
||||
});
|
||||
|
||||
testWidgets('the focused segment shows a focus ring', (tester) async {
|
||||
await _pumpTabBar(tester, onValueChanged: (_) {});
|
||||
expect(_hasFocusRing(tester, 'One'), isFalse);
|
||||
|
||||
await _focusFirstSegment(tester);
|
||||
|
||||
expect(_hasFocusRing(tester, 'One'), isTrue);
|
||||
expect(_hasFocusRing(tester, 'Two'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('activating a focused segment reports its value', (tester) async {
|
||||
final values = <int?>[];
|
||||
await _pumpTabBar(tester, onValueChanged: values.add);
|
||||
await _focusFirstSegment(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(_focusedLabel(), 'Two');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
expect(values, <int>[1]);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(values, <int>[1, 1]);
|
||||
});
|
||||
|
||||
testWidgets('traversal skips a disabled segment', (tester) async {
|
||||
final values = <int?>[];
|
||||
await _pumpTabBar(
|
||||
tester,
|
||||
onValueChanged: values.add,
|
||||
disabledChildren: const {1},
|
||||
);
|
||||
await _focusFirstSegment(tester);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
|
||||
expect(_focusedLabel(), 'Three');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
expect(values, <int>[2]);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
Widget _action() {
|
||||
return CommonFloatingActionButton(
|
||||
key: const ValueKey('action'),
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const GlyphIcon(AppGlyphs.add),
|
||||
label: 'action',
|
||||
);
|
||||
}
|
||||
@@ -36,9 +39,10 @@ bool _isActionFocused() {
|
||||
void main() {
|
||||
testWidgets('non-TV CommonScaffold keeps the Scaffold FAB', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: CommonScaffold(
|
||||
title: 'page',
|
||||
isTV: false,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
@@ -55,9 +59,10 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: CommonScaffold(
|
||||
title: 'page',
|
||||
isTV: true,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
@@ -85,13 +90,14 @@ void main() {
|
||||
final outsideFocus = FocusNode();
|
||||
addTearDown(outsideFocus.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Column(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Column(
|
||||
children: [
|
||||
Focus(focusNode: outsideFocus, child: const SizedBox()),
|
||||
Expanded(
|
||||
child: CommonScaffold(
|
||||
appBar: AppBar(title: const Text('page')),
|
||||
title: 'page',
|
||||
isTV: true,
|
||||
floatingActionButton: _action(),
|
||||
body: _content(),
|
||||
@@ -109,31 +115,4 @@ void main() {
|
||||
|
||||
expect(_isActionFocused(), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('FloatLayout uses the same fixed top action layout on TV', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FloatLayout(
|
||||
isTV: true,
|
||||
floatingWidget: _action(),
|
||||
child: _content(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final action = find.byKey(const ValueKey('action'));
|
||||
final firstItem = find.byKey(const ValueKey('item0'));
|
||||
final initialActionTop = tester.getTopLeft(action).dy;
|
||||
|
||||
expect(initialActionTop, lessThan(tester.getTopLeft(firstItem).dy));
|
||||
|
||||
await tester.drag(firstItem, const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(action).dy, initialActionTop);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/hotkey_manager.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
@@ -12,6 +13,8 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('desktop escape runs the back flow and exits search', (
|
||||
tester,
|
||||
@@ -47,7 +50,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
|
||||
@@ -89,7 +92,7 @@ void main() {
|
||||
NavigationItemsState(
|
||||
value: [
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.space_dashboard),
|
||||
glyph: AppGlyphs.dashboard,
|
||||
label: PageLabel.dashboard,
|
||||
builder: (_) => CommonScaffold(
|
||||
key: const GlobalObjectKey(PageLabel.dashboard),
|
||||
@@ -103,7 +106,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
NavigationItem(
|
||||
icon: const Icon(Icons.construction),
|
||||
glyph: AppGlyphs.tools,
|
||||
label: PageLabel.tools,
|
||||
builder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
@@ -133,9 +136,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationSidebar), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.tap(find.byGlyph(AppGlyphs.search));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'needle');
|
||||
|
||||
Reference in New Issue
Block a user