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:
chen08209
2026-09-25 03:57:04 +08:00
parent ee6a49713a
commit 841d6035d5
78 changed files with 13296 additions and 1515 deletions
+18
View File
@@ -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/`:
+3
View File
@@ -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.
+2 -1
View File
@@ -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(),
+1
View File
@@ -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';
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
),
),
),
),
+24 -9
View File
@@ -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
View File
@@ -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;
+25 -22
View File
@@ -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;
+15 -3
View File
@@ -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
View File
@@ -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();
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
);
+19 -8
View File
@@ -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
View File
@@ -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,
),
),
);
+227
View File
@@ -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,
),
};
}
}
-57
View File
@@ -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
View File
@@ -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,
);
}
}
+140
View File
@@ -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;
}
+86
View File
@@ -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
+65 -10
View File
@@ -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;
+166 -8
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+131 -19
View File
@@ -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
View File
@@ -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;
}
}
+223
View File
@@ -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);
}
}
+19
View File
@@ -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
View File
@@ -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);
}
+564
View File
@@ -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
View File
@@ -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,
+18 -1
View File
@@ -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(
+82
View File
@@ -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,
+8 -1
View File
@@ -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';
+71 -14
View File
@@ -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', (
+38 -8
View File
@@ -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);
+115
View File
@@ -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());
+85 -36
View File
@@ -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);
+19 -1
View File
@@ -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
View File
@@ -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 {
+328
View File
@@ -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);
});
}
+4 -32
View File
@@ -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);
});
}
+261
View File
@@ -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);
});
}
+58 -8
View File
@@ -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);
});
}
+133 -1
View File
@@ -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');
});
}
+251
View File
@@ -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),
);
}
+44
View File
@@ -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));
});
}
+102
View File
@@ -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);
});
}
+132
View File
@@ -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);
});
}
+646
View File
@@ -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,
);
},
);
}
+80
View File
@@ -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
View File
@@ -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);
});
}
+205
View File
@@ -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);
});
}
+103
View File
@@ -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);
});
}
+226
View File
@@ -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']);
});
}
+12 -12
View File
@@ -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,
]);
});
}
+141
View File
@@ -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);
});
}
+81
View File
@@ -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);
+171 -21
View File
@@ -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));
},
);
}
+79
View File
@@ -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);
});
}
}
+100
View File
@@ -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);
},
);
}
}
+236
View File
@@ -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));
});
}
+393
View File
@@ -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);
});
});
}
+521
View File
@@ -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);
});
}
+137
View File
@@ -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]);
});
}
+16 -37
View File
@@ -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);
});
}
+8 -5
View File
@@ -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');