feat(ui): stack bottom sheets and add context menus
Changelog: Stacked sheets, context menus and a sort mode
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/exception.dart';
|
||||
import 'package:fl_clash/common/network_error.dart';
|
||||
import 'package:fl_clash/core/desktop/launch_policy.dart';
|
||||
import 'package:fl_clash/core/method.dart';
|
||||
@@ -25,11 +27,25 @@ String userFacingErrorMessage(Object error, AppLocalizations appLocalizations) {
|
||||
return networkErrorMessage(error, appLocalizations) ??
|
||||
coreLaunchBlockedMessage(error, appLocalizations) ??
|
||||
switch (error) {
|
||||
AgeSecretKeyRequiredException() =>
|
||||
appLocalizations.ageSecretKeyRequiredTip,
|
||||
CoreMethodException(code: 'rule_set_empty') =>
|
||||
appLocalizations.ruleSetEmptyTip,
|
||||
CoreMethodException(code: 'rule_set_mixed') =>
|
||||
appLocalizations.ruleSetMixedTip,
|
||||
CoreMethodException(code: 'rule_set_invalid') =>
|
||||
appLocalizations.ruleSetInvalidTip,
|
||||
CoreMethodException(:final message) => message,
|
||||
_ => error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
String delayFailureText(int delay, AppLocalizations appLocalizations) {
|
||||
return delay == delayTimedOutValue
|
||||
? appLocalizations.timeout
|
||||
: appLocalizations.delayFailed;
|
||||
}
|
||||
|
||||
Locale? getLocaleForString(String? localString) {
|
||||
if (localString == null) return null;
|
||||
final localSplit = localString.split('_');
|
||||
|
||||
@@ -90,6 +90,26 @@ extension BuildContextExtension on BuildContext {
|
||||
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
|
||||
/// Tighter than Material's 24 dp line so a row with a subtitle stays compact.
|
||||
TextStyle? get listTitleStyle => textTheme.bodyLarge?.copyWith(
|
||||
height: 22 / 16,
|
||||
color: colorScheme.onSurface,
|
||||
);
|
||||
|
||||
TextStyle? get listSubtitleStyle => textTheme.bodyMedium?.copyWith(
|
||||
height: 20 / 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
TextStyle? get listCaptionStyle => textTheme.bodySmall
|
||||
?.adjustSize(1)
|
||||
.copyWith(color: colorScheme.onSurfaceVariant);
|
||||
|
||||
TextStyle? get sectionHeaderStyle => textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant.opacity80,
|
||||
);
|
||||
|
||||
AppLocalizations get appLocalizations => AppLocalizations.of(this);
|
||||
|
||||
T? findLastStateOfType<T extends State>() {
|
||||
|
||||
+118
-43
@@ -81,33 +81,10 @@ class Dialogs {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showAllUpdatingMessagesDialog(
|
||||
List<UpdatingMessage> messages,
|
||||
) async {
|
||||
return showCommonDialog<bool>(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
backgroundColor: context.colorScheme.surfaceContainerLow,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
title: appLocalizations.tip,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(appLocalizations.confirm),
|
||||
),
|
||||
],
|
||||
child: generateSectionV3(
|
||||
items: messages.map(
|
||||
(message) => _UpdatingMessageItem(message: message),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Future<void> showFailureDetails(List<UpdatingMessage> failures) {
|
||||
return showSheet(
|
||||
context: _context,
|
||||
builder: (_) => _FailureDetailsSheet(failures: failures),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,14 +109,41 @@ class Dialogs {
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String label, String url})?> showNamedUrlInput({
|
||||
Future<String?> showPasswordInput({required String title}) {
|
||||
return showCommonDialog<String>(
|
||||
child: InputDialog(
|
||||
title: title,
|
||||
value: '',
|
||||
labelText: currentAppLocalizations.password,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showAgeSecretKeyInput() {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return showCommonDialog<String>(
|
||||
child: InputDialog(
|
||||
title: appLocalizations.ageEncryptedProfile,
|
||||
value: '',
|
||||
labelText: appLocalizations.ageSecretKey,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
validator: (value) => value == null || value.trim().isEmpty
|
||||
? appLocalizations.emptyTip(appLocalizations.ageSecretKey)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<NamedUrl?> showNamedUrlInput({
|
||||
required String title,
|
||||
String label = '',
|
||||
String url = '',
|
||||
FormFieldValidator<String>? labelValidator,
|
||||
FormFieldValidator<String>? urlValidator,
|
||||
}) {
|
||||
return showCommonDialog<({String label, String url})>(
|
||||
}) async {
|
||||
final res = await showCommonDialog<List<NamedUrl>>(
|
||||
child: NamedUrlDialog(
|
||||
title: title,
|
||||
label: label,
|
||||
@@ -148,6 +152,31 @@ class Dialogs {
|
||||
urlValidator: urlValidator,
|
||||
),
|
||||
);
|
||||
return res?.single;
|
||||
}
|
||||
|
||||
/// A batch skips the urls in [existingUrls].
|
||||
Future<List<NamedUrl>?> showNamedUrlsInput({
|
||||
required String title,
|
||||
String? urlLabel,
|
||||
String? batchTip,
|
||||
FormFieldValidator<String>? labelValidator,
|
||||
FormFieldValidator<String>? urlValidator,
|
||||
Set<String> existingUrls = const {},
|
||||
bool withAgeSecretKey = false,
|
||||
}) {
|
||||
return showCommonDialog<List<NamedUrl>>(
|
||||
child: NamedUrlDialog(
|
||||
title: title,
|
||||
urlLabel: urlLabel,
|
||||
batchTip: batchTip,
|
||||
labelValidator: labelValidator,
|
||||
urlValidator: urlValidator,
|
||||
batch: true,
|
||||
existingUrls: existingUrls,
|
||||
withAgeSecretKey: withAgeSecretKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showNotifier(
|
||||
@@ -162,6 +191,30 @@ class Dialogs {
|
||||
);
|
||||
}
|
||||
|
||||
void showFailures(List<UpdatingMessage> failures) {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
switch (failures) {
|
||||
case []:
|
||||
return;
|
||||
case [final failure]:
|
||||
showNotifier(
|
||||
appLocalizations.failedItem(failure.label, failure.message),
|
||||
level: MessageLevel.error,
|
||||
);
|
||||
default:
|
||||
showNotifier(
|
||||
appLocalizations.failedCount(failures.length),
|
||||
level: MessageLevel.error,
|
||||
actionState: MessageActionState(
|
||||
actionText: appLocalizations.view,
|
||||
action: () {
|
||||
unawaited(showFailureDetails(failures));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openUrl(String url) async {
|
||||
final res = await showMessage(
|
||||
message: TextSpan(text: url),
|
||||
@@ -175,23 +228,45 @@ class Dialogs {
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdatingMessageItem extends StatelessWidget {
|
||||
final UpdatingMessage message;
|
||||
class _FailureDetailsSheet extends StatelessWidget {
|
||||
final List<UpdatingMessage> failures;
|
||||
|
||||
const _UpdatingMessageItem({required this.message});
|
||||
const _FailureDetailsSheet({required this.failures});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecorationListItem(
|
||||
minVerticalPadding: 12,
|
||||
title: TooltipText(
|
||||
text: Text(message.label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
subtitle: TooltipText(
|
||||
text: Text(
|
||||
message.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
final labelsByMessage = <String, List<String>>{};
|
||||
for (final failure in failures) {
|
||||
(labelsByMessage[failure.message] ??= []).add(failure.label);
|
||||
}
|
||||
final groups = labelsByMessage.entries.toList();
|
||||
return CommonScaffold(
|
||||
title: context.appLocalizations.errorDetails,
|
||||
body: SelectionArea(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(top: context.contentTopPadding, bottom: 20),
|
||||
itemCount: groups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 24.mAp),
|
||||
itemBuilder: (_, index) {
|
||||
final MapEntry(key: message, value: labels) = groups[index];
|
||||
return generateSectionV3(
|
||||
items: [
|
||||
for (final label in labels)
|
||||
DecorationListItem(
|
||||
title: TooltipText(
|
||||
text: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
footer: message,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ extension DnsQueryInitiatorL10n on DnsQueryInitiator {
|
||||
DnsQueryInitiator.app => appLocalizations.app,
|
||||
DnsQueryInitiator.rule => appLocalizations.rule,
|
||||
DnsQueryInitiator.direct => appLocalizations.direct,
|
||||
DnsQueryInitiator.proxy => appLocalizations.proxyNode,
|
||||
DnsQueryInitiator.proxy => appLocalizations.proxies,
|
||||
DnsQueryInitiator.other => appLocalizations.other,
|
||||
};
|
||||
}
|
||||
@@ -85,16 +85,6 @@ extension HotActionL10n on HotAction {
|
||||
}
|
||||
}
|
||||
|
||||
extension RouteModeL10n on RouteMode {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
return switch (this) {
|
||||
RouteMode.bypassPrivate => appLocalizations.routeModeBypassPrivate,
|
||||
RouteMode.config => appLocalizations.routeModeConfig,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension RestoreStrategyL10n on RestoreStrategy {
|
||||
String get label {
|
||||
final appLocalizations = currentAppLocalizations;
|
||||
|
||||
+14
-2
@@ -49,6 +49,17 @@ Size windowControlsOverSidebar({required bool isMacOS, required int version}) {
|
||||
return isMacOS && version > 10 ? macOSTrafficLightsArea : Size.zero;
|
||||
}
|
||||
|
||||
/// The mobile view holds the traffic lights in its window header, so only a
|
||||
/// page filling a wider window meets them.
|
||||
Size windowControlsOverPage({
|
||||
required bool isMacOS,
|
||||
required int version,
|
||||
required bool isMobileView,
|
||||
}) {
|
||||
if (isMobileView) return Size.zero;
|
||||
return windowControlsOverSidebar(isMacOS: isMacOS, version: version);
|
||||
}
|
||||
|
||||
int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
final columns = max((viewWidth / 250).ceil(), 2);
|
||||
return switch (proxiesLayout) {
|
||||
@@ -60,7 +71,8 @@ int getProxiesColumns(double viewWidth, ProxiesLayout proxiesLayout) {
|
||||
|
||||
double get cardSpacing => 12.mAp;
|
||||
|
||||
const profileItemMinWidth = 270.0;
|
||||
const profileItemMinWidth = 300.0;
|
||||
const _profilesMaxColumns = 3;
|
||||
|
||||
int getProfilesColumns(
|
||||
double viewWidth, {
|
||||
@@ -68,5 +80,5 @@ int getProfilesColumns(
|
||||
double minItemWidth = profileItemMinWidth,
|
||||
}) {
|
||||
final columns = (viewWidth + spacing) / (minItemWidth + spacing);
|
||||
return max(columns.floor(), 1);
|
||||
return columns.floor().clamp(1, _profilesMaxColumns);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -112,6 +114,36 @@ class Measure {
|
||||
);
|
||||
}
|
||||
|
||||
double get listTitleHeight {
|
||||
return _measureMap.updateCacheValue(
|
||||
'listTitleHeight',
|
||||
() => computeTextSize(Text('X', style: context.listTitleStyle)).height,
|
||||
);
|
||||
}
|
||||
|
||||
double get listSubtitleHeight {
|
||||
return _measureMap.updateCacheValue(
|
||||
'listSubtitleHeight',
|
||||
() => computeTextSize(Text('X', style: context.listSubtitleStyle)).height,
|
||||
);
|
||||
}
|
||||
|
||||
double get sectionHeaderHeight {
|
||||
return _measureMap.updateCacheValue(
|
||||
'sectionHeaderHeight',
|
||||
() =>
|
||||
computeTextSize(Text('X', style: context.sectionHeaderStyle)).height,
|
||||
);
|
||||
}
|
||||
|
||||
double get listRowHeight =>
|
||||
max(listRowMinHeight, listTitleHeight + 2 * listRowVerticalPadding);
|
||||
|
||||
double get listTwoLineRowHeight => max(
|
||||
listRowMinHeight,
|
||||
listTitleHeight + listSubtitleHeight + 2 * listRowVerticalPadding,
|
||||
);
|
||||
|
||||
double get bodySmallHeight {
|
||||
return _measureMap.updateCacheValue(
|
||||
'bodySmallHeight',
|
||||
|
||||
+78
-23
@@ -2,35 +2,69 @@ import 'dart:async';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/deferred_push.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
import 'package:fl_clash/widgets/page_transition.dart';
|
||||
import 'package:fl_clash/widgets/sheet_navigator.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
class BaseNavigator {
|
||||
static Future<T?> push<T>(BuildContext context, Widget child) async {
|
||||
static PageRoute<T> _routeOf<T>(BuildContext context, Widget child) {
|
||||
if (!context.isMobileView) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).push<T>(CommonDesktopRoute(builder: (context) => child));
|
||||
return CommonDesktopRoute(builder: (context) => child);
|
||||
}
|
||||
return CommonRoute(builder: (context) => child);
|
||||
}
|
||||
|
||||
static Future<T?> push<T>(BuildContext context, Widget child) async {
|
||||
return Navigator.of(context).push<T>(_routeOf(context, child));
|
||||
}
|
||||
|
||||
static Future<T?> pushReplacement<T>(BuildContext context, Widget child) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
).push<T>(CommonRoute(builder: (context) => child));
|
||||
).pushReplacement<T, void>(_routeOf(context, child));
|
||||
}
|
||||
}
|
||||
|
||||
// Work a page starts on arrival drops frames while the route still animates.
|
||||
// A sheet's first page arrives with the sheet, so the sheet's route counts too.
|
||||
Iterable<ModalRoute<Object?>> _arrivalRoutes(ModalRoute<Object?>? page) sync* {
|
||||
var route = page;
|
||||
while (route != null) {
|
||||
yield route;
|
||||
final navigator = route.navigator;
|
||||
if (navigator == null ||
|
||||
!navigator.mounted ||
|
||||
navigator.widget is! SheetPagesNavigator) {
|
||||
return;
|
||||
}
|
||||
route = ModalRoute.of(navigator.context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> whenRouteSettled(BuildContext context) async {
|
||||
final route = ModalRoute.of(context);
|
||||
final routes = _arrivalRoutes(ModalRoute.of(context));
|
||||
for (final route in routes) {
|
||||
await _whenSettled(route);
|
||||
}
|
||||
}
|
||||
|
||||
bool isRouteArriving(BuildContext context) => _arrivalRoutes(
|
||||
ModalRoute.of(context),
|
||||
).any((route) => route.offstage || (route.animation?.isAnimating ?? false));
|
||||
|
||||
Future<void> _whenSettled(ModalRoute<Object?> route) async {
|
||||
// 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) {
|
||||
while (route.offstage && route.isActive) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
final animation = route?.animation;
|
||||
final animation = route.animation;
|
||||
if (animation == null || !animation.isAnimating) {
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +86,8 @@ const commonSharedXPageTransitions = SharedAxisPageTransitionsBuilder(
|
||||
fillColor: Colors.transparent,
|
||||
);
|
||||
|
||||
class CommonDesktopRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
class CommonDesktopRoute<T> extends PageRoute<T>
|
||||
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
CommonDesktopRoute({required this.builder});
|
||||
@@ -100,11 +135,23 @@ class CommonDesktopRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 200);
|
||||
}
|
||||
|
||||
class CommonRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
class CommonRoute<T> extends PageRoute<T>
|
||||
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
CommonRoute({required this.builder});
|
||||
|
||||
bool _overSettledPage = false;
|
||||
|
||||
@override
|
||||
void didChangePrevious(Route<dynamic>? previousRoute) {
|
||||
super.didChangePrevious(previousRoute);
|
||||
_overSettledPage =
|
||||
previousRoute is CommonRoute &&
|
||||
previousRoute.animation!.isCompleted &&
|
||||
previousRoute.secondaryAnimation!.isDismissed;
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@@ -134,24 +181,32 @@ class CommonRoute<T> extends PageRoute<T> with DragBackRouteMixin<T> {
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
final dragging = isDragBackActive;
|
||||
final surface = context.colorScheme.surface;
|
||||
return dragBackDetector(
|
||||
isDragBackActive
|
||||
? dragBackSlide(context, animation, child)
|
||||
: SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
fillColor: context.colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
dragBackSlide(
|
||||
context,
|
||||
animation,
|
||||
SharedXPageTransition(
|
||||
animation: dragging ? kAlwaysCompleteAnimation : animation,
|
||||
secondaryAnimation: dragging
|
||||
? kAlwaysDismissedAnimation
|
||||
: secondaryAnimation,
|
||||
color: surface,
|
||||
slide: SharedXPageTransition.pageSlide,
|
||||
overSettledPage: _overSettledPage,
|
||||
uncoveredByGesture: navigator!.userGestureInProgress,
|
||||
child: ColoredBox(
|
||||
color: dragging ? Colors.transparent : surface,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 300);
|
||||
Duration get transitionDuration => SharedXPageTransition.duration;
|
||||
}
|
||||
|
||||
final Animatable<Offset> _kRightMiddleTween = Tween<Offset>(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/scroll.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -79,6 +80,10 @@ class NextClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
return NextClampingScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
// iOS momentum is sized for its friction fling; a clamping one multiplies it.
|
||||
@override
|
||||
double carriedMomentum(double existingVelocity) => 0;
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
@@ -119,6 +124,54 @@ class NextClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
}
|
||||
}
|
||||
|
||||
class FollowEndScrollPhysics extends NextClampingScrollPhysics {
|
||||
const FollowEndScrollPhysics({required this.isFollowing, super.parent});
|
||||
|
||||
final ValueGetter<bool> isFollowing;
|
||||
|
||||
static bool isAtEnd(ScrollMetrics metrics) =>
|
||||
metrics.pixels >= metrics.maxScrollExtent - precisionErrorTolerance;
|
||||
|
||||
@override
|
||||
FollowEndScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return FollowEndScrollPhysics(
|
||||
isFollowing: isFollowing,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double adjustPositionForNewDimensions({
|
||||
required ScrollMetrics oldPosition,
|
||||
required ScrollMetrics newPosition,
|
||||
required bool isScrolling,
|
||||
required double velocity,
|
||||
}) {
|
||||
if (isFollowing() && isAtEnd(oldPosition)) {
|
||||
return newPosition.maxScrollExtent;
|
||||
}
|
||||
return super.adjustPositionForNewDimensions(
|
||||
oldPosition: oldPosition,
|
||||
newPosition: newPosition,
|
||||
isScrolling: isScrolling,
|
||||
velocity: velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double zeroDividerExtentEstimation(int? index, double crossAxisExtent) {
|
||||
return index != null && index.isEven ? 100 : 0;
|
||||
}
|
||||
|
||||
mixin ScrubbableScrollPosition on ScrollPosition {
|
||||
bool isScrubbing = false;
|
||||
}
|
||||
|
||||
/// Drags move the sheet until it opens fully, so a scrollbar hides till then.
|
||||
mixin SheetScrollController on ScrollController {
|
||||
ValueListenable<bool> get sheetOpen;
|
||||
}
|
||||
|
||||
const _stopEpsilon = 1e-3;
|
||||
|
||||
/// Settles each scroll on a whole row of [RowSnapScrollController.itemExtent].
|
||||
|
||||
@@ -255,6 +255,16 @@ extension AppShapeThemeExt on ThemeData {
|
||||
shape: AppShape.top(AppCorner.xxl),
|
||||
),
|
||||
snackBarTheme: snackBarTheme.copyWith(shape: AppShape.xs),
|
||||
tooltipTheme: tooltipTheme.copyWith(
|
||||
// A decoration replaces Material's tooltip colours too, so restate them.
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.xs,
|
||||
color: switch (brightness) {
|
||||
Brightness.dark => Colors.white.withAlpha(230),
|
||||
Brightness.light => Colors.grey[700]!.withAlpha(230),
|
||||
},
|
||||
),
|
||||
),
|
||||
chipTheme: chipTheme.copyWith(shape: AppShape.sm),
|
||||
popupMenuTheme: popupMenuTheme.copyWith(shape: AppShape.md),
|
||||
menuTheme: MenuThemeData(
|
||||
|
||||
+89
-27
@@ -292,35 +292,48 @@ abstract final class AppGlyphs {
|
||||
], matchTextDirection: true);
|
||||
|
||||
static const sync = Glyph([
|
||||
GlyphArc(12, 12, 7.07, math.pi * 1.1, math.pi * 0.65, role: GlyphRole.line),
|
||||
GlyphArc(
|
||||
12,
|
||||
12,
|
||||
7.78,
|
||||
math.pi * 1.144,
|
||||
math.pi * 0.889,
|
||||
role: GlyphRole.line,
|
||||
),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(17, 7),
|
||||
GlyphVertex(18.5, 8.5),
|
||||
GlyphVertex(5, 5),
|
||||
GlyphVertex(5, 8.6, 0.4),
|
||||
GlyphVertex(8.6, 8.6),
|
||||
], role: GlyphRole.line),
|
||||
GlyphArc(
|
||||
12,
|
||||
12,
|
||||
7.78,
|
||||
math.pi * 0.144,
|
||||
math.pi * 0.889,
|
||||
role: GlyphRole.line,
|
||||
),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(18.5, 3.1),
|
||||
GlyphVertex(18.5, 8.5, 0.4),
|
||||
GlyphVertex(13.1, 8.5),
|
||||
], role: GlyphRole.line),
|
||||
GlyphArc(12, 12, 7.07, math.pi * 0.1, math.pi * 0.65, role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(7, 17),
|
||||
GlyphVertex(5.5, 15.5),
|
||||
], role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(5.5, 20.9),
|
||||
GlyphVertex(5.5, 15.5, 0.4),
|
||||
GlyphVertex(10.9, 15.5),
|
||||
GlyphVertex(19, 19),
|
||||
GlyphVertex(19, 15.4, 0.4),
|
||||
GlyphVertex(15.4, 15.4),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
static const refresh = Glyph([
|
||||
GlyphArc(12, 13, 7.07, math.pi * 0.1, math.pi * 1.57, role: GlyphRole.line),
|
||||
GlyphArc(
|
||||
12,
|
||||
12,
|
||||
7.78,
|
||||
math.pi * 0.19,
|
||||
math.pi * 1.666,
|
||||
role: GlyphRole.line,
|
||||
),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(18.5, 4.1),
|
||||
GlyphVertex(18.5, 9.5, 0.4),
|
||||
GlyphVertex(13.1, 9.5),
|
||||
], closed: true),
|
||||
GlyphVertex(19, 5),
|
||||
GlyphVertex(19, 8.6, 0.4),
|
||||
GlyphVertex(15.4, 8.6),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
static const reset = Glyph([
|
||||
@@ -393,21 +406,22 @@ abstract final class AppGlyphs {
|
||||
]);
|
||||
|
||||
static const addCircle = Glyph([
|
||||
GlyphCircle(12, 12, 8.8),
|
||||
GlyphCircle(12, 12, 8.8, role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(12, 7.8),
|
||||
GlyphVertex(12, 16.2),
|
||||
], role: GlyphRole.detail),
|
||||
], role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(7.8, 12),
|
||||
GlyphVertex(16.2, 12),
|
||||
], role: GlyphRole.detail),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
static const remove = Glyph([
|
||||
static const removeCircle = Glyph([
|
||||
GlyphCircle(12, 12, 8.8, role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(5, 12),
|
||||
GlyphVertex(19, 12),
|
||||
GlyphVertex(7.8, 12),
|
||||
GlyphVertex(16.2, 12),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
@@ -534,6 +548,21 @@ abstract final class AppGlyphs {
|
||||
], role: GlyphRole.line),
|
||||
], matchTextDirection: true);
|
||||
|
||||
static const filter = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(4, 7),
|
||||
GlyphVertex(20, 7),
|
||||
], role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(7, 12),
|
||||
GlyphVertex(17, 12),
|
||||
], role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(10, 17),
|
||||
GlyphVertex(14, 17),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
static const sortAlpha = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(3.2, 10),
|
||||
@@ -937,6 +966,27 @@ abstract final class AppGlyphs {
|
||||
], role: GlyphRole.detail),
|
||||
]);
|
||||
|
||||
static const file = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(5, 3, 2.5),
|
||||
GlyphVertex(13.5, 3, 1),
|
||||
GlyphVertex(19, 8.5, 1),
|
||||
GlyphVertex(19, 21, 2.5),
|
||||
GlyphVertex(5, 21, 2.5),
|
||||
], closed: true),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(13.5, 3.2),
|
||||
GlyphVertex(13.5, 8.5, 1.5),
|
||||
GlyphVertex(18.8, 8.5),
|
||||
], role: GlyphRole.detail),
|
||||
]);
|
||||
|
||||
static const cloud = Glyph([
|
||||
GlyphPath(
|
||||
'M7 19.4A4.2 4.2 0 0 1 5.06 11.47A6 6 0 1 1 16.95 9.83A4.8 4.8 0 0 1 16.4 19.4Z',
|
||||
),
|
||||
]);
|
||||
|
||||
static const cloudDownload = Glyph([
|
||||
GlyphPath(
|
||||
'M7 19.4A4.2 4.2 0 0 1 5.06 11.47A6 6 0 1 1 16.95 9.83A4.8 4.8 0 0 1 16.4 19.4Z',
|
||||
@@ -1463,6 +1513,18 @@ abstract final class AppGlyphs {
|
||||
GlyphCircle(14.6, 19.8, 1.1, role: GlyphRole.line, solid: true),
|
||||
]);
|
||||
|
||||
static const font = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(5.2, 19.6),
|
||||
GlyphVertex(12, 4.4, 0.6),
|
||||
GlyphVertex(18.8, 19.6),
|
||||
], role: GlyphRole.line),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(7.7, 14),
|
||||
GlyphVertex(16.3, 14),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
|
||||
static const textSize = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(3.2, 4.4),
|
||||
|
||||
+459
-263
File diff suppressed because it is too large
Load Diff
+385
-210
File diff suppressed because it is too large
Load Diff
+471
-269
File diff suppressed because it is too large
Load Diff
+364
-199
File diff suppressed because it is too large
Load Diff
+1127
-373
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,8 @@ import 'package:re_highlight/styles/atom-one-dark.dart';
|
||||
import 'package:re_highlight/styles/atom-one-light.dart';
|
||||
import 'package:rust_api/rust_api.dart' show isRustLibInitialized;
|
||||
|
||||
export 'package:fl_clash/features/editor/editor.dart' show EditorSchema;
|
||||
export 'package:fl_clash/features/editor/editor.dart'
|
||||
show EditorSchema, readRelaxed;
|
||||
|
||||
void _requireRustLib() {
|
||||
if (!isRustLibInitialized) {
|
||||
|
||||
+18
-10
@@ -6,6 +6,7 @@ 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:flutter/foundation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -78,7 +79,7 @@ class _HomeShell extends ConsumerWidget {
|
||||
profilesProvider.select((profiles) => profiles.isNotEmpty),
|
||||
);
|
||||
final isDashboard = ref.watch(
|
||||
currentPageLabelProvider.select((label) => label == PageLabel.dashboard),
|
||||
shownPageLabelProvider.select((label) => label == PageLabel.dashboard),
|
||||
);
|
||||
return Material(
|
||||
color: context.colorScheme.surface,
|
||||
@@ -224,7 +225,7 @@ class _NavigationPage extends StatelessWidget {
|
||||
return Consumer(
|
||||
builder: (_, ref, child) {
|
||||
final isActive = ref.watch(
|
||||
currentPageLabelProvider.select((label) => label == item.label),
|
||||
shownPageLabelProvider.select((label) => label == item.label),
|
||||
);
|
||||
// A kept-alive page off screen still ticks its animations, and
|
||||
// each tick asks for a frame.
|
||||
@@ -277,15 +278,24 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
@override
|
||||
void didUpdateWidget(covariant _HomePageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.navigationItems.length != widget.navigationItems.length) {
|
||||
if (!listEquals(
|
||||
_labelsOf(oldWidget.navigationItems),
|
||||
_labelsOf(widget.navigationItems),
|
||||
)) {
|
||||
_order = null;
|
||||
_updatePageController();
|
||||
}
|
||||
}
|
||||
|
||||
int get _pageIndex {
|
||||
final pageLabel = ref.read(currentPageLabelProvider);
|
||||
return widget.navigationItems.indexWhere((item) => item.label == pageLabel);
|
||||
static List<PageLabel> _labelsOf(List<NavigationItem> items) => [
|
||||
for (final item in items) item.label,
|
||||
];
|
||||
|
||||
int get _pageIndex => _indexOf(ref.read(currentPageLabelProvider));
|
||||
|
||||
int _indexOf(PageLabel pageLabel) {
|
||||
final shown = shownPageLabelOf(pageLabel, widget.navigationItems);
|
||||
return widget.navigationItems.indexWhere((item) => item.label == shown);
|
||||
}
|
||||
|
||||
Future<void> _toPage(
|
||||
@@ -295,9 +305,7 @@ class _HomePageViewState extends ConsumerState<_HomePageView> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final index = widget.navigationItems.indexWhere(
|
||||
(item) => item.label == pageLabel,
|
||||
);
|
||||
final index = _indexOf(pageLabel);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
@@ -437,7 +445,7 @@ class HomeBackScopeContainer extends ConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
return CommonPopScope(
|
||||
onPop: (context) async {
|
||||
final pageLabel = ref.read(currentPageLabelProvider);
|
||||
final pageLabel = ref.read(shownPageLabelProvider);
|
||||
final realContext =
|
||||
GlobalObjectKey(pageLabel).currentContext ?? context;
|
||||
final canPop = Navigator.canPop(realContext);
|
||||
|
||||
@@ -35,9 +35,28 @@ NavigationItemsState currentNavigationItemsState(Ref ref) {
|
||||
);
|
||||
}
|
||||
|
||||
PageLabel shownPageLabelOf(
|
||||
PageLabel pageLabel,
|
||||
List<NavigationItem> navigationItems,
|
||||
) {
|
||||
if (navigationItems.isEmpty ||
|
||||
navigationItems.any((item) => item.label == pageLabel)) {
|
||||
return pageLabel;
|
||||
}
|
||||
return navigationItems.first.label;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
PageLabel shownPageLabel(Ref ref) {
|
||||
return shownPageLabelOf(
|
||||
ref.watch(currentPageLabelProvider),
|
||||
ref.watch(currentNavigationItemsStateProvider).value,
|
||||
);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
NavigationState navigationState(Ref ref) {
|
||||
final pageLabel = ref.watch(currentPageLabelProvider);
|
||||
final pageLabel = ref.watch(shownPageLabelProvider);
|
||||
final navigationItems = ref.watch(currentNavigationItemsStateProvider).value;
|
||||
final viewMode = ref.watch(viewModeProvider);
|
||||
final locale = ref.watch(appSettingProvider).locale;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'inherited.dart';
|
||||
import 'list.dart';
|
||||
|
||||
/// Each entry runs on a controller of its own, so a change made while another
|
||||
/// entry still moves leaves that one running.
|
||||
class EntryTransitions<K> {
|
||||
final TickerProvider vsync;
|
||||
|
||||
/// Runs once [K] has collapsed, outside the frame that finished it, so the
|
||||
/// owner may drop the entry with setState even while being built.
|
||||
final ValueChanged<K> onLeft;
|
||||
|
||||
final _controllers = <K, AnimationController>{};
|
||||
final _leaving = <K>{};
|
||||
final _positions = <K, ItemPosition>{};
|
||||
|
||||
EntryTransitions({required this.vsync, required this.onLeft});
|
||||
|
||||
Animation<double>? animationOf(K key) => _controllers[key];
|
||||
|
||||
bool isLeaving(K key) => _leaving.contains(key);
|
||||
|
||||
Iterable<K> get leaving => _leaving;
|
||||
|
||||
/// Moves [entries] to [next], keeping the ones it drops while they leave.
|
||||
List<T> sync<T>(
|
||||
List<T> entries,
|
||||
List<T> next,
|
||||
K Function(T item) keyOf,
|
||||
Duration duration,
|
||||
) {
|
||||
final keys = {for (final item in next) keyOf(item)};
|
||||
final shown = {
|
||||
for (final entry in entries)
|
||||
if (!isLeaving(keyOf(entry))) keyOf(entry),
|
||||
};
|
||||
for (final key in shown) {
|
||||
if (!keys.contains(key)) {
|
||||
leave(key, duration);
|
||||
}
|
||||
}
|
||||
for (final key in keys) {
|
||||
if (!shown.contains(key)) {
|
||||
enter(key, duration);
|
||||
}
|
||||
}
|
||||
return keepLeaving(entries, next, keyOf);
|
||||
}
|
||||
|
||||
/// Places the staying [keys] in one run of rounded rows, ahead of
|
||||
/// [trailing] rows that close it; a leaving key keeps the place it had.
|
||||
void place(Iterable<K> keys, {int trailing = 0}) {
|
||||
final staying = keys.where((key) => !isLeaving(key)).toList();
|
||||
final count = staying.length + trailing;
|
||||
for (final (index, key) in staying.indexed) {
|
||||
_positions[key] = ItemPosition.get(index, count);
|
||||
}
|
||||
}
|
||||
|
||||
ItemPosition positionOf(K key) => _positions[key] ?? ItemPosition.middle;
|
||||
|
||||
void enter(K key, Duration duration) {
|
||||
_leaving.remove(key);
|
||||
(_controllers[key] ??= _create(key, 0))
|
||||
..duration = duration
|
||||
..forward();
|
||||
}
|
||||
|
||||
void leave(K key, Duration duration) {
|
||||
if (!_leaving.add(key)) {
|
||||
return;
|
||||
}
|
||||
(_controllers[key] ??= _create(key, 1))
|
||||
..duration = duration
|
||||
..reverse();
|
||||
}
|
||||
|
||||
AnimationController _create(K key, double value) {
|
||||
final controller = AnimationController(vsync: vsync, value: value);
|
||||
controller.addStatusListener((status) {
|
||||
if (!status.isDismissed || !_leaving.remove(key)) {
|
||||
return;
|
||||
}
|
||||
_controllers.remove(key);
|
||||
scheduleMicrotask(() {
|
||||
controller.dispose();
|
||||
_positions.remove(key);
|
||||
onLeft(key);
|
||||
});
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
_controllers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
List<T> keepLeaving<T>(
|
||||
List<T> previous,
|
||||
List<T> next,
|
||||
Object? Function(T item) keyOf,
|
||||
) {
|
||||
final keys = {for (final item in next) keyOf(item)};
|
||||
final before = <Object?, List<T>>{};
|
||||
var pending = <T>[];
|
||||
for (final item in previous) {
|
||||
final key = keyOf(item);
|
||||
if (!keys.contains(key)) {
|
||||
pending.add(item);
|
||||
} else if (pending.isNotEmpty) {
|
||||
before[key] = pending;
|
||||
pending = [];
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final item in next) ...[...?before[keyOf(item)], item],
|
||||
...pending,
|
||||
];
|
||||
}
|
||||
|
||||
class EntryTransition extends StatelessWidget {
|
||||
static final _fade = CurveTween(curve: const Interval(0.5, 1));
|
||||
static final _size = CurveTween(curve: Curves.easeInOutCubic);
|
||||
|
||||
final Animation<double>? animation;
|
||||
final bool leaving;
|
||||
final Widget child;
|
||||
|
||||
const EntryTransition({
|
||||
super.key,
|
||||
required this.animation,
|
||||
this.leaving = false,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final animation = this.animation ?? kAlwaysCompleteAnimation;
|
||||
return IgnorePointer(
|
||||
ignoring: leaving,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation.drive(_size),
|
||||
alignment: Alignment.topCenter,
|
||||
child: FadeTransition(opacity: animation.drive(_fade), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// With [grouped], a child collapsing out keeps its place in the run of
|
||||
/// rounded rows while the others close up around it.
|
||||
class AnimatedEntries extends StatefulWidget {
|
||||
final List<Widget> children;
|
||||
final bool grouped;
|
||||
|
||||
const AnimatedEntries({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.grouped = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedEntries> createState() => _AnimatedEntriesState();
|
||||
}
|
||||
|
||||
class _AnimatedEntriesState extends State<AnimatedEntries>
|
||||
with TickerProviderStateMixin {
|
||||
late final _transitions = EntryTransitions<Key?>(
|
||||
vsync: this,
|
||||
onLeft: _handleLeft,
|
||||
);
|
||||
late var _entries = widget.children;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedEntries oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_entries = _transitions.sync(
|
||||
_entries,
|
||||
widget.children,
|
||||
(child) => child.key,
|
||||
context.motionDuration(commonDuration),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transitions.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleLeft(Key? key) {
|
||||
if (!mounted || widget.children.any((child) => child.key == key)) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_entries = [
|
||||
for (final entry in _entries)
|
||||
if (entry.key != key) entry,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.grouped) {
|
||||
_transitions.place(_entries.map((entry) => entry.key));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final entry in _entries)
|
||||
EntryTransition(
|
||||
key: entry.key,
|
||||
animation: _transitions.animationOf(entry.key),
|
||||
leaving: _transitions.isLeaving(entry.key),
|
||||
child: widget.grouped
|
||||
? ItemPositionProvider(
|
||||
position: _transitions.positionOf(entry.key),
|
||||
child: entry,
|
||||
)
|
||||
: entry,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget generateAnimatedSection({
|
||||
String? title,
|
||||
required List<Widget> items,
|
||||
String? footer,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
if (items.isNotEmpty && title != null) ListHeader(title: title),
|
||||
AnimatedEntries(grouped: true, children: items),
|
||||
if (items.isNotEmpty && footer != null) ListFooter(text: footer),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -33,14 +33,12 @@ class AnimatedVisibility extends StatefulWidget {
|
||||
final _VisibilityMotion _motion;
|
||||
final Widget child;
|
||||
|
||||
/// Creates a sidebar transition that moves to the left when hidden.
|
||||
const AnimatedVisibility.sidebar({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.child,
|
||||
}) : _motion = _VisibilityMotion.sidebar;
|
||||
|
||||
/// Creates a bottom-navigation transition that moves through the bottom.
|
||||
const AnimatedVisibility.bottomNavigation({
|
||||
super.key,
|
||||
required this.visible,
|
||||
|
||||
+42
-8
@@ -112,6 +112,42 @@ class _CommonFloatingActionButtonState
|
||||
}
|
||||
}
|
||||
|
||||
/// Backed by the surface so the card edge it straddles stays out of the ring.
|
||||
class CornerBadgeButton extends StatelessWidget {
|
||||
const CornerBadgeButton({
|
||||
super.key,
|
||||
required this.glyph,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Glyph glyph;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
return SizedBox.square(
|
||||
dimension: 24,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surface,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
iconSize: 24,
|
||||
padding: EdgeInsets.zero,
|
||||
color: colorScheme.primary,
|
||||
onPressed: onPressed,
|
||||
icon: GlyphIcon(glyph),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MoreActionButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final String label;
|
||||
@@ -132,14 +168,11 @@ class MoreActionButton extends StatelessWidget {
|
||||
radius: AppCorner.xl,
|
||||
onPressed: onPressed,
|
||||
child: ListTile(
|
||||
minTileHeight: 0,
|
||||
minVerticalPadding: 0,
|
||||
titleTextStyle: context.textTheme.bodyMedium?.toJetBrainsMono,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
title: Text(label, style: context.textTheme.bodyLarge),
|
||||
minTileHeight: listRowMinHeight,
|
||||
minVerticalPadding: listRowVerticalPadding,
|
||||
titleTextStyle: context.listTitleStyle,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(label),
|
||||
trailing:
|
||||
trailing ?? const GlyphIcon(AppGlyphs.chevronForward, size: 18),
|
||||
),
|
||||
@@ -150,6 +183,7 @@ class MoreActionButton extends StatelessWidget {
|
||||
|
||||
enum TonalButtonSize {
|
||||
bar(button: 40, icon: 22),
|
||||
section(button: 36, icon: 20),
|
||||
compact(button: 32, icon: 20);
|
||||
|
||||
const TonalButtonSize({required this.button, required this.icon});
|
||||
|
||||
+24
-8
@@ -61,9 +61,7 @@ class InfoHeader extends StatelessWidget {
|
||||
info.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: context.sectionHeaderStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -73,7 +71,7 @@ class InfoHeader extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
if (actions.isNotEmpty)
|
||||
SizedBox(
|
||||
height: globalState.measure.titleSmallHeight + 16.ap,
|
||||
height: globalState.measure.sectionHeaderHeight + 16.ap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -106,9 +104,13 @@ class CommonCard extends StatelessWidget {
|
||||
this.isError = false,
|
||||
this.enterActionsOnRight = false,
|
||||
this.skipTraversal = false,
|
||||
this.statesController,
|
||||
this.inert = false,
|
||||
required this.child,
|
||||
}) : isSelected = isSelected ?? false;
|
||||
|
||||
/// Ignores presses in the enabled colors, so toggling it animates nothing.
|
||||
final bool inert;
|
||||
final bool enterAnimated;
|
||||
final bool enterActionsOnRight;
|
||||
final bool skipTraversal;
|
||||
@@ -124,6 +126,7 @@ class CommonCard extends StatelessWidget {
|
||||
final CommonCardType type;
|
||||
final double? radius;
|
||||
final OutlinedBorder? shape;
|
||||
final WidgetStatesController? statesController;
|
||||
|
||||
BorderSide _buildBorderSide(BuildContext context, Set<WidgetState> states) {
|
||||
final colorScheme = context.colorScheme;
|
||||
@@ -210,7 +213,8 @@ class CommonCard extends StatelessWidget {
|
||||
return switch (type == CommonCardType.filled) {
|
||||
true => FilledButton(
|
||||
focusNode: focusNode,
|
||||
onLongPress: onLongPress,
|
||||
statesController: statesController,
|
||||
onLongPress: inert ? null : onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
@@ -219,6 +223,10 @@ class CommonCard extends StatelessWidget {
|
||||
iconSize: commonCardIconSize,
|
||||
iconColor: _buildIconColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
disabledIconColor: inert ? _buildIconColor(context) : null,
|
||||
disabledForegroundColor: inert
|
||||
? _buildForegroundColor(context)
|
||||
: null,
|
||||
side: BorderSide.none,
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
@@ -229,12 +237,13 @@ class CommonCard extends StatelessWidget {
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
onPressed: inert ? null : onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
false => OutlinedButton(
|
||||
focusNode: focusNode,
|
||||
onLongPress: onLongPress,
|
||||
statesController: statesController,
|
||||
onLongPress: inert ? null : onLongPress,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
@@ -244,13 +253,20 @@ class CommonCard extends StatelessWidget {
|
||||
iconColor: _buildIconColor(context),
|
||||
backgroundColor: _buildBackgroundColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
disabledIconColor: inert ? _buildIconColor(context) : null,
|
||||
disabledBackgroundColor: inert
|
||||
? _buildBackgroundColor(context)
|
||||
: null,
|
||||
disabledForegroundColor: inert
|
||||
? _buildForegroundColor(context)
|
||||
: null,
|
||||
elevation: 0,
|
||||
).copyWith(
|
||||
side: WidgetStateProperty.resolveWith(
|
||||
(states) => _buildBorderSide(context, states),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
onPressed: inert ? null : onPressed,
|
||||
child: childWidget,
|
||||
),
|
||||
};
|
||||
|
||||
+215
-6
@@ -1,6 +1,10 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class CommonChip extends StatelessWidget {
|
||||
final String label;
|
||||
@@ -80,15 +84,26 @@ class TonalChip extends StatelessWidget {
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
static const _padding = EdgeInsets.symmetric(horizontal: 6, vertical: 1);
|
||||
|
||||
static double widthOf(BuildContext context, String label) =>
|
||||
_labelWidth(context, label) + _padding.horizontal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = context.textTheme.labelMedium!.copyWith(
|
||||
color: foregroundColor,
|
||||
);
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(color: foregroundColor),
|
||||
padding: _padding,
|
||||
child: _CapCentered(
|
||||
fontSize: style.fontSize!,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Material(
|
||||
@@ -102,6 +117,200 @@ class TonalChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font's ascent outweighs its descent, so a centered line box sits low.
|
||||
class _CapCentered extends SingleChildRenderObjectWidget {
|
||||
static const _capHeightPerEm = 0.7;
|
||||
|
||||
final double fontSize;
|
||||
|
||||
const _CapCentered({required this.fontSize, required super.child});
|
||||
|
||||
double _capHeight(BuildContext context) =>
|
||||
MediaQuery.textScalerOf(context).scale(fontSize) * _capHeightPerEm;
|
||||
|
||||
@override
|
||||
_RenderCapCentered createRenderObject(BuildContext context) =>
|
||||
_RenderCapCentered(_capHeight(context));
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderCapCentered renderObject,
|
||||
) {
|
||||
renderObject.capHeight = _capHeight(context);
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderCapCentered extends RenderShiftedBox {
|
||||
_RenderCapCentered(this._capHeight) : super(null);
|
||||
|
||||
double _capHeight;
|
||||
|
||||
set capHeight(double value) {
|
||||
if (_capHeight == value) {
|
||||
return;
|
||||
}
|
||||
_capHeight = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
double _shift(Size size, double alphabeticBaseline) =>
|
||||
(size.height + _capHeight) / 2 - alphabeticBaseline;
|
||||
|
||||
@override
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) =>
|
||||
child?.getDryLayout(constraints) ?? constraints.smallest;
|
||||
|
||||
@override
|
||||
double? computeDryBaseline(
|
||||
covariant BoxConstraints constraints,
|
||||
TextBaseline baseline,
|
||||
) {
|
||||
final child = this.child;
|
||||
if (child == null) {
|
||||
return null;
|
||||
}
|
||||
final alphabetic = child.getDryBaseline(
|
||||
constraints,
|
||||
TextBaseline.alphabetic,
|
||||
);
|
||||
final result = child.getDryBaseline(constraints, baseline);
|
||||
if (alphabetic == null || result == null) {
|
||||
return null;
|
||||
}
|
||||
return result + _shift(child.getDryLayout(constraints), alphabetic);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final child = this.child;
|
||||
if (child == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
child.layout(constraints, parentUsesSize: true);
|
||||
size = child.size;
|
||||
final baseline = child.getDistanceToBaseline(TextBaseline.alphabetic)!;
|
||||
(child.parentData! as BoxParentData).offset = Offset(
|
||||
0,
|
||||
_shift(size, baseline),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double _labelWidth(BuildContext context, String label) => globalState.measure
|
||||
.computeTextSize(Text(label, style: context.textTheme.labelMedium))
|
||||
.width;
|
||||
|
||||
class ExpireChip extends StatelessWidget {
|
||||
final DateTime expire;
|
||||
|
||||
const ExpireChip({super.key, required this.expire});
|
||||
|
||||
static const _padding = EdgeInsets.symmetric(horizontal: 8, vertical: 2);
|
||||
|
||||
static double widthOf(BuildContext context, DateTime expire) =>
|
||||
_labelWidth(context, expire.show) + _padding.horizontal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final color = expire.isBeforeNow ? colorScheme.error : colorScheme.primary;
|
||||
return Tooltip(
|
||||
message: context.appLocalizations.expireTime,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: color.opacity15,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Padding(
|
||||
padding: _padding,
|
||||
child: Text(
|
||||
expire.show,
|
||||
maxLines: 1,
|
||||
style: context.textTheme.labelMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A title followed by an optional count chip and an expiry chip. The expiry
|
||||
/// chip gives way before it would squeeze the title below half the row.
|
||||
class ChipTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final String? chip;
|
||||
final DateTime? expire;
|
||||
final TextStyle? style;
|
||||
|
||||
const ChipTitle({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.chip,
|
||||
this.expire,
|
||||
this.style,
|
||||
});
|
||||
|
||||
static const _gap = 8.0;
|
||||
|
||||
bool _fitsExpire(BuildContext context, double maxWidth, DateTime expire) {
|
||||
final chip = this.chip;
|
||||
final titleWidth = globalState.measure
|
||||
.computeTextSize(
|
||||
Text(title, style: DefaultTextStyle.of(context).style.merge(style)),
|
||||
)
|
||||
.width;
|
||||
final chipsWidth =
|
||||
(chip == null ? 0 : TonalChip.widthOf(context, chip) + _gap) +
|
||||
ExpireChip.widthOf(context, expire) +
|
||||
_gap;
|
||||
return maxWidth - chipsWidth >= min(titleWidth, maxWidth / 2);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = this.style;
|
||||
final chip = this.chip;
|
||||
final expire = this.expire;
|
||||
final text = Text(
|
||||
title,
|
||||
style: style,
|
||||
strutStyle: StrutStyle.fromTextStyle(
|
||||
DefaultTextStyle.of(context).style.merge(style),
|
||||
forceStrutHeight: true,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textWidthBasis: TextWidthBasis.longestLine,
|
||||
);
|
||||
if (chip == null && expire == null) {
|
||||
return text;
|
||||
}
|
||||
final colorScheme = context.colorScheme;
|
||||
Widget row({required bool showExpire}) => Row(
|
||||
spacing: _gap,
|
||||
children: [
|
||||
Flexible(child: text),
|
||||
if (chip != null)
|
||||
TonalChip(
|
||||
label: chip,
|
||||
color: colorScheme.secondaryContainer,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
if (showExpire && expire != null) ExpireChip(expire: expire),
|
||||
],
|
||||
);
|
||||
if (expire == null) {
|
||||
return row(showExpire: false);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
row(showExpire: _fitsExpire(context, constraints.maxWidth, expire)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MetaChip extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'inherited.dart';
|
||||
|
||||
/// A titled section whose header folds its entries away. The entries unfold
|
||||
/// with the section, and the ones nearest the middle of what fits on screen
|
||||
/// fade in first.
|
||||
class SliverCollapsibleSection extends StatefulWidget {
|
||||
final String label;
|
||||
final int itemCount;
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
final bool collapsible;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
const SliverCollapsibleSection({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.collapsible = true,
|
||||
this.initiallyExpanded = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SliverCollapsibleSection> createState() =>
|
||||
_SliverCollapsibleSectionState();
|
||||
}
|
||||
|
||||
class _SliverCollapsibleSectionState extends State<SliverCollapsibleSection>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// Only orders the fade; a wrong guess shifts which entry leads it, not what
|
||||
// ends up shown.
|
||||
static const _estimatedEntryExtent = 64.0;
|
||||
static const _fade = 0.5;
|
||||
|
||||
late var _expanded = widget.initiallyExpanded;
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
value: _isOpen(widget.collapsible) ? 1 : 0,
|
||||
);
|
||||
late final _size = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
late final _turns = Tween<double>(begin: -0.25, end: 0).animate(_size);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addStatusListener((_) => setState(() {}));
|
||||
}
|
||||
|
||||
bool _isOpen(bool collapsible) => _expanded || !collapsible;
|
||||
|
||||
void _animate() {
|
||||
_isOpen(widget.collapsible) ? _controller.forward() : _controller.reverse();
|
||||
}
|
||||
|
||||
void _handleToggle() {
|
||||
setState(() {
|
||||
_expanded = !_expanded;
|
||||
});
|
||||
_animate();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SliverCollapsibleSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_isOpen(oldWidget.collapsible) != _isOpen(widget.collapsible)) {
|
||||
_animate();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_size.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Half of what fits on screen, in entries.
|
||||
double _halfOf(int count) {
|
||||
final fitting = MediaQuery.sizeOf(context).height / _estimatedEntryExtent;
|
||||
return math.min(count.toDouble(), fitting) / 2;
|
||||
}
|
||||
|
||||
Widget _buildEntry(BuildContext context, int index) => ItemPositionProvider(
|
||||
position: ItemPosition.get(index, widget.itemCount),
|
||||
child: widget.itemBuilder(context, index),
|
||||
);
|
||||
|
||||
Widget _buildUnfoldingEntry(int index, double half) {
|
||||
final distance = (half - index - 0.5).abs() / half;
|
||||
final start = math.min(1.0, distance) * (1 - _fade);
|
||||
return SlideTransition(
|
||||
position: _size.drive(
|
||||
Tween(begin: Offset(0, -index - 0.5), end: Offset.zero),
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: _size.drive(CurveTween(curve: Interval(start, start + _fade))),
|
||||
child: _buildEntry(context, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final colorScheme = context.colorScheme;
|
||||
final textTheme = context.textTheme;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.collapsible ? _handleToggle : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 8),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: widget.label),
|
||||
TextSpan(
|
||||
text: ' (${widget.itemCount})',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
RotationTransition(
|
||||
turns: _turns,
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.chevronDown,
|
||||
size: 20,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// An entry is at least [kMinInteractiveDimension] tall, so these fill the
|
||||
/// screen whatever lies past them.
|
||||
int _unfoldingCount(int count) {
|
||||
final fitting =
|
||||
MediaQuery.sizeOf(context).height ~/ kMinInteractiveDimension + 1;
|
||||
return math.min(count, fitting);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = widget.itemCount;
|
||||
final half = _halfOf(count);
|
||||
final unfolding = _unfoldingCount(count);
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
if (_controller.isCompleted)
|
||||
SliverList.builder(itemCount: count, itemBuilder: _buildEntry)
|
||||
else if (!_controller.isDismissed)
|
||||
SliverToBoxAdapter(
|
||||
child: SizeTransition(
|
||||
sizeFactor: _size,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < unfolding; index++)
|
||||
_buildUnfoldingEntry(index, half),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ class ConfigTextItem extends _ConfigItem<String> {
|
||||
return ListItem.input(
|
||||
leading: leading,
|
||||
title: Text(label),
|
||||
subtitle: showValueAsSubtitle
|
||||
subtitle: showValueAsSubtitle && value.isNotEmpty
|
||||
? Text(value)
|
||||
: buildSubtitle(appLocalizations),
|
||||
dialogTitle: label,
|
||||
@@ -208,9 +208,9 @@ class ConfigListEditItem extends _ConfigItem<List<String>> {
|
||||
subtitle:
|
||||
buildSubtitle(appLocalizations) ??
|
||||
Text(
|
||||
value.isEmpty ? appLocalizations.none : value.join(', '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
value.isEmpty
|
||||
? appLocalizations.none
|
||||
: appLocalizations.itemsCount(value.length),
|
||||
),
|
||||
widget: ListEditView(
|
||||
title: label,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'focus.dart';
|
||||
import 'popup.dart';
|
||||
import 'sortable.dart';
|
||||
|
||||
const _touchDevices = {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.invertedStylus,
|
||||
};
|
||||
|
||||
/// A touch long press, the menu key and, on a remote, a held select key keep
|
||||
/// the menu clear of the row, which a finger or the focus highlight would
|
||||
/// otherwise leave covered.
|
||||
class ContextMenuRegion extends StatefulWidget {
|
||||
final List<CommonPopupMenuItem>? menuItems;
|
||||
final Widget child;
|
||||
|
||||
const ContextMenuRegion({super.key, this.menuItems, required this.child});
|
||||
|
||||
@override
|
||||
State<ContextMenuRegion> createState() => _ContextMenuRegionState();
|
||||
}
|
||||
|
||||
class _ContextMenuRegionState extends State<ContextMenuRegion> {
|
||||
Timer? _holdTimer;
|
||||
LogicalKeyboardKey? _heldKey;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_holdTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(Offset globalPosition, {bool avoid = false}) {
|
||||
final navigator = Navigator.maybeOf(context);
|
||||
final navigatorBox = navigator?.context.findRenderObject();
|
||||
final box = context.findRenderObject();
|
||||
if (navigator == null ||
|
||||
navigatorBox is! RenderBox ||
|
||||
box is! RenderBox ||
|
||||
!box.hasSize) {
|
||||
return;
|
||||
}
|
||||
final point = navigatorBox.globalToLocal(globalPosition);
|
||||
final rowRect = MatrixUtils.transformRect(
|
||||
box.getTransformTo(navigatorBox),
|
||||
Offset.zero & box.size,
|
||||
);
|
||||
unawaited(
|
||||
navigator.push(
|
||||
CommonPopupRoute<void>(
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
).modalBarrierDismissLabel,
|
||||
placement: PopupPlacement.belowPoint,
|
||||
anchorOf: () => point & Size.zero,
|
||||
avoid: avoid ? rowRect : null,
|
||||
builder: (_) => CommonPopupMenu(items: widget.menuItems!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAtRow() {
|
||||
final box = context.findRenderObject();
|
||||
if (box is! RenderBox || !box.hasSize) {
|
||||
return;
|
||||
}
|
||||
_open(box.localToGlobal(box.size.bottomLeft(Offset.zero)), avoid: true);
|
||||
}
|
||||
|
||||
// Swallows the rest of the hold, which would otherwise repeat into the menu
|
||||
// as presses of its first item.
|
||||
void _handleHold() {
|
||||
final key = _heldKey;
|
||||
_heldKey = null;
|
||||
KeyEventResult swallow(KeyEvent event) {
|
||||
if (event.logicalKey != key) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyUpEvent) {
|
||||
FocusManager.instance.removeEarlyKeyEventHandler(swallow);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
FocusManager.instance.addEarlyKeyEventHandler(swallow);
|
||||
_openAtRow();
|
||||
}
|
||||
|
||||
KeyEventResult _handleRemoteSelect(KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
switch (event) {
|
||||
case KeyDownEvent():
|
||||
_heldKey = key;
|
||||
_holdTimer?.cancel();
|
||||
_holdTimer = Timer(kLongPressTimeout, _handleHold);
|
||||
case KeyRepeatEvent():
|
||||
if (key != _heldKey) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
case KeyUpEvent():
|
||||
if (key != _heldKey) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
_heldKey = null;
|
||||
_holdTimer?.cancel();
|
||||
final focused = FocusManager.instance.primaryFocus?.context;
|
||||
if (focused != null) {
|
||||
Actions.maybeInvoke(focused, const ActivateIntent());
|
||||
}
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
if (key == LogicalKeyboardKey.contextMenu ||
|
||||
(key == LogicalKeyboardKey.f10 &&
|
||||
HardwareKeyboard.instance.isShiftPressed)) {
|
||||
if (event is KeyDownEvent) {
|
||||
_openAtRow();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (remoteSelectKeys.contains(key) &&
|
||||
RemoteFocusAdapter.isEnabledOf(context)) {
|
||||
return _handleRemoteSelect(event);
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = widget.menuItems;
|
||||
final enabled =
|
||||
items != null && items.isNotEmpty && !SortableItem.isSorting(context);
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: enabled ? (_, event) => _handleKey(event) : null,
|
||||
child: GestureDetector(
|
||||
onSecondaryTapUp: enabled
|
||||
? (details) => _open(details.globalPosition)
|
||||
: null,
|
||||
child: GestureDetector(
|
||||
supportedDevices: _touchDevices,
|
||||
onLongPressStart: enabled
|
||||
? (details) {
|
||||
Feedback.forLongPress(context);
|
||||
_open(details.globalPosition, avoid: true);
|
||||
}
|
||||
: null,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Starts a push transition one frame after the page is first built, so a long
|
||||
/// first build does not swallow the start of the transition.
|
||||
mixin DeferredPushRouteMixin<T> on TransitionRoute<T> {
|
||||
_TickerCapture? _vsync;
|
||||
|
||||
@override
|
||||
AnimationController createAnimationController() {
|
||||
final vsync = _vsync = _TickerCapture(navigator!);
|
||||
return AnimationController(
|
||||
duration: transitionDuration,
|
||||
reverseDuration: reverseTransitionDuration,
|
||||
debugLabel: debugLabel,
|
||||
vsync: vsync,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TickerFuture didPush() {
|
||||
final ticker = _vsync?.ticker;
|
||||
if (ticker == null || ticker.muted) {
|
||||
return super.didPush();
|
||||
}
|
||||
ticker.muted = true;
|
||||
final pushed = super.didPush();
|
||||
final context = navigator!.context;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
ticker.muted = !TickerMode.getValuesNotifier(context).value.enabled;
|
||||
});
|
||||
return pushed;
|
||||
}
|
||||
}
|
||||
|
||||
class _TickerCapture implements TickerProvider {
|
||||
_TickerCapture(this._parent);
|
||||
|
||||
final TickerProvider _parent;
|
||||
Ticker? ticker;
|
||||
|
||||
@override
|
||||
Ticker createTicker(TickerCallback onTick) {
|
||||
return ticker = _parent.createTicker(onTick);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
enum ExternalDismissibleEffect { normal, resize }
|
||||
|
||||
class ExternalDismissible extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onDismissed;
|
||||
final bool dismiss;
|
||||
final ExternalDismissibleEffect effect;
|
||||
|
||||
const ExternalDismissible({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.dismiss,
|
||||
this.onDismissed,
|
||||
this.effect = ExternalDismissibleEffect.normal,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExternalDismissible> createState() => _ExternalDismissibleState();
|
||||
}
|
||||
|
||||
class _ExternalDismissibleState extends State<ExternalDismissible>
|
||||
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
||||
late final AnimationController _controller;
|
||||
Animation<Offset>? _slideAnimation;
|
||||
Animation<double>? _fadeAnimation;
|
||||
late Animation<double> _resizeAnimation;
|
||||
|
||||
bool _isDismissing = false;
|
||||
|
||||
bool get _isNormal => widget.effect == ExternalDismissibleEffect.normal;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
_initAnimations();
|
||||
if (widget.dismiss) {
|
||||
_dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
void _initAnimations() {
|
||||
const curve = Curves.fastOutSlowIn;
|
||||
|
||||
if (_isNormal) {
|
||||
_slideAnimation =
|
||||
Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(1.0, 0.0),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 1.0, curve: curve),
|
||||
),
|
||||
);
|
||||
|
||||
_resizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.3, 1.0, curve: curve),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_fadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.6, curve: curve),
|
||||
),
|
||||
);
|
||||
|
||||
_resizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.2, 1.0, curve: curve),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => _isDismissing;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExternalDismissible oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!oldWidget.dismiss && widget.dismiss) {
|
||||
_dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dismiss() async {
|
||||
if (_isDismissing) return;
|
||||
if (!mounted) return;
|
||||
_isDismissing = true;
|
||||
updateKeepAlive();
|
||||
await _controller.forward();
|
||||
_isDismissing = false;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
widget.onDismissed?.call();
|
||||
updateKeepAlive();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
Widget content = widget.child;
|
||||
|
||||
if (_slideAnimation != null) {
|
||||
content = SlideTransition(position: _slideAnimation!, child: content);
|
||||
}
|
||||
|
||||
if (_fadeAnimation != null) {
|
||||
content = FadeTransition(opacity: _fadeAnimation!, child: content);
|
||||
}
|
||||
|
||||
return SizeTransition(
|
||||
alignment: AlignmentGeometry.center,
|
||||
sizeFactor: _resizeAnimation,
|
||||
axis: Axis.vertical,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
const double _flingVelocity = 1.0;
|
||||
const Duration _settleDuration = Duration(milliseconds: 350);
|
||||
const double _dragEdge = 1e-6;
|
||||
|
||||
final Animatable<Offset> _slideTween = Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
);
|
||||
const Animation<Offset> _noSlide = AlwaysStoppedAnimation(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
|
||||
@@ -23,23 +25,38 @@ mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
@protected
|
||||
void didStartDragBack() {}
|
||||
|
||||
/// Called once the route has settled after a drag back, popped or not.
|
||||
@protected
|
||||
void didEndDragBack() {}
|
||||
|
||||
Widget dragBackDetector(Widget child) {
|
||||
return _DragBackDetector(route: this, child: child);
|
||||
}
|
||||
|
||||
/// Follows the finger while a drag back is active and stays still
|
||||
/// otherwise. A route keeps it in its tree either way: wrapping the page
|
||||
/// only once the drag starts would move the page to a new parent, which
|
||||
/// rebuilds all of it.
|
||||
Widget dragBackSlide(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return SlideTransition(
|
||||
position: animation.drive(_slideTween),
|
||||
position: _dragBackActive ? animation.drive(_slideTween) : _noSlide,
|
||||
textDirection: Directionality.of(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canDragBack => isCurrent && popGestureEnabled;
|
||||
// popGestureEnabled no longer waits for the route to be fully uncovered or
|
||||
// for another drag back to settle, and NavigatorResizable would settle on
|
||||
// a pop still running under the drag.
|
||||
bool get _canDragBack =>
|
||||
isCurrent &&
|
||||
popGestureEnabled &&
|
||||
secondaryAnimation!.isDismissed &&
|
||||
!popGestureInProgress;
|
||||
|
||||
void _startDragBack() {
|
||||
_dragBackActive = true;
|
||||
@@ -47,8 +64,17 @@ mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
navigator!.didStartUserGesture();
|
||||
}
|
||||
|
||||
// Either end reached mid-drag would flip the animation's status, which
|
||||
// routes take for a finished transition: TransitionRoute turns the route
|
||||
// opaque and OpenContainer hides or returns its tile. The release settles
|
||||
// the last bit.
|
||||
void _updateDragBack(double delta) {
|
||||
controller!.value -= delta;
|
||||
final controller = this.controller!;
|
||||
controller.value = clampDouble(
|
||||
controller.value - delta,
|
||||
_dragEdge,
|
||||
1 - _dragEdge,
|
||||
);
|
||||
}
|
||||
|
||||
void _endDragBack(double velocity) {
|
||||
@@ -100,6 +126,7 @@ mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
return;
|
||||
}
|
||||
_dragBackActive = false;
|
||||
didEndDragBack();
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
|
||||
@@ -109,6 +136,7 @@ mixin DragBackRouteMixin<T> on ModalRoute<T> {
|
||||
return;
|
||||
}
|
||||
_dragBackActive = false;
|
||||
didEndDragBack();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (navigator.mounted) {
|
||||
navigator.didStopUserGesture();
|
||||
|
||||
+63
-65
@@ -156,9 +156,10 @@ abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
|
||||
child: Text(appLocalizations.selectAll),
|
||||
)
|
||||
else
|
||||
FilledButton.tonal(
|
||||
IconButton(
|
||||
tooltip: appLocalizations.add,
|
||||
onPressed: _handleAdd,
|
||||
child: Text(appLocalizations.add),
|
||||
icon: const GlyphIcon(AppGlyphs.addCircle),
|
||||
),
|
||||
],
|
||||
body: NullStatusSwitcher(
|
||||
@@ -255,75 +256,71 @@ class _ListEditViewState extends _EditViewState<ListEditView, String> {
|
||||
@override
|
||||
Object popResult(List<String> entries) => entries;
|
||||
|
||||
String get _valueLabel => widget.valueLabel ?? context.appLocalizations.value;
|
||||
|
||||
ParsedInput<String> _parseBatch(String text) {
|
||||
return parseListInput(
|
||||
text,
|
||||
existing: _entries.toSet(),
|
||||
maxLength: widget.itemMaxLength,
|
||||
);
|
||||
}
|
||||
|
||||
String _issueMessage(InputIssue issue) {
|
||||
return context.appLocalizations.maxLengthTip(
|
||||
_valueLabel,
|
||||
widget.itemMaxLength!,
|
||||
);
|
||||
}
|
||||
|
||||
String _toEntry(String? key, String value) =>
|
||||
parseListInput(value).entries.single;
|
||||
|
||||
@override
|
||||
Future<List<String>?> showEntryDialog(String? entry) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final label = _valueLabel;
|
||||
|
||||
String? validator(String? value) {
|
||||
final parsed = parseListInput(
|
||||
value ?? '',
|
||||
maxLength: widget.itemMaxLength,
|
||||
);
|
||||
if (!parsed.isValid) {
|
||||
return appLocalizations.maxLengthTip(label, widget.itemMaxLength!);
|
||||
}
|
||||
if (parsed.entries.isEmpty) {
|
||||
return appLocalizations.emptyTip(label);
|
||||
}
|
||||
if (parsed.entries.length > 1) {
|
||||
return appLocalizations.singleValueTip(label);
|
||||
}
|
||||
final next = parsed.entries.single;
|
||||
if (next != entry && _entries.contains(next)) {
|
||||
return appLocalizations.existsTip(label);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return dialogs.showCommonDialog<List<String>>(
|
||||
child: EntryDialog<String>(
|
||||
title: entry == null ? appLocalizations.add : appLocalizations.edit,
|
||||
valueField: Field(
|
||||
label: label,
|
||||
value: entry ?? '',
|
||||
validator: validator,
|
||||
),
|
||||
valueMaxLength: widget.itemMaxLength,
|
||||
toEntry: _toEntry,
|
||||
batch: entry != null
|
||||
? null
|
||||
: BatchInput(
|
||||
label: widget.title,
|
||||
formatTip: appLocalizations.batchListInputTip,
|
||||
parse: _parseBatch,
|
||||
issueMessage: _issueMessage,
|
||||
),
|
||||
),
|
||||
return showListEntryDialog(
|
||||
context,
|
||||
title: widget.title,
|
||||
entries: _entries,
|
||||
entry: entry,
|
||||
valueLabel: widget.valueLabel,
|
||||
itemMaxLength: widget.itemMaxLength,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>?> showListEntryDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<String> entries,
|
||||
String? entry,
|
||||
String? valueLabel,
|
||||
int? itemMaxLength,
|
||||
}) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final label = valueLabel ?? appLocalizations.value;
|
||||
|
||||
String? validator(String? value) {
|
||||
final parsed = parseListInput(value ?? '', maxLength: itemMaxLength);
|
||||
if (!parsed.isValid) {
|
||||
return appLocalizations.maxLengthTip(label, itemMaxLength!);
|
||||
}
|
||||
if (parsed.entries.isEmpty) {
|
||||
return appLocalizations.emptyTip(label);
|
||||
}
|
||||
if (parsed.entries.length > 1) {
|
||||
return appLocalizations.singleValueTip(label);
|
||||
}
|
||||
final next = parsed.entries.single;
|
||||
if (next != entry && entries.contains(next)) {
|
||||
return appLocalizations.existsTip(label);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return dialogs.showCommonDialog<List<String>>(
|
||||
child: EntryDialog<String>(
|
||||
title: entry == null ? appLocalizations.add : appLocalizations.edit,
|
||||
valueField: Field(label: label, value: entry ?? '', validator: validator),
|
||||
valueMaxLength: itemMaxLength,
|
||||
toEntry: (_, value) => parseListInput(value).entries.single,
|
||||
batch: entry != null
|
||||
? null
|
||||
: BatchInput(
|
||||
label: title,
|
||||
formatTip: appLocalizations.batchListInputTip,
|
||||
parse: (text) => parseListInput(
|
||||
text,
|
||||
existing: entries.toSet(),
|
||||
maxLength: itemMaxLength,
|
||||
),
|
||||
issueMessage: (_) =>
|
||||
appLocalizations.maxLengthTip(label, itemMaxLength!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MapEditView extends ConsumerStatefulWidget {
|
||||
final String title;
|
||||
final Map<String, String> entries;
|
||||
@@ -411,6 +408,7 @@ class _MapEditViewState
|
||||
widget.valueMaxLength!,
|
||||
),
|
||||
InputIssueKind.missingValue => appLocalizations.emptyTip(_valueLabel),
|
||||
InputIssueKind.invalidUrl => appLocalizations.urlTip(_valueLabel),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -8,12 +8,14 @@ import 'package:material_ui/material_ui.dart';
|
||||
class EffectGestureDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final GestureLongPressCallback? onLongPress;
|
||||
final GestureTapCallback? onSecondaryTap;
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
const EffectGestureDetector({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onLongPress,
|
||||
this.onSecondaryTap,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@@ -52,6 +54,7 @@ class _EffectGestureDetectorState extends State<EffectGestureDetector>
|
||||
});
|
||||
},
|
||||
onTap: widget.onTap,
|
||||
onSecondaryTap: widget.onSecondaryTap,
|
||||
onLongPressEnd: (_) {
|
||||
setState(() {
|
||||
_scale = 1;
|
||||
@@ -130,16 +133,17 @@ Widget commonProxyDecorator(
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return ProxyDecoratorProvider(
|
||||
isProxyDecorator: true,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, Widget? child) {
|
||||
final double animValue = Curves.easeInOut.transform(animation.value);
|
||||
final double scale = lerpDouble(1, 1.02, animValue)!;
|
||||
return Transform.scale(scale: scale, child: child);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, Widget? child) {
|
||||
final double animValue = Curves.easeInOut.transform(animation.value);
|
||||
final double scale = lerpDouble(1, 1.02, animValue)!;
|
||||
return ProxyDecoratorProvider(
|
||||
isProxyDecorator: true,
|
||||
lift: animValue,
|
||||
child: Transform.scale(scale: scale, child: child),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
+30
-6
@@ -151,6 +151,13 @@ class PageTraversalPolicy extends ReadingOrderTraversalPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
final remoteSelectKeys = {
|
||||
LogicalKeyboardKey.select,
|
||||
LogicalKeyboardKey.enter,
|
||||
LogicalKeyboardKey.numpadEnter,
|
||||
LogicalKeyboardKey.gameButtonA,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
@@ -164,6 +171,10 @@ class RemoteFocusAdapter extends StatefulWidget {
|
||||
required this.child,
|
||||
});
|
||||
|
||||
static bool isEnabledOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_RemoteScope>()?.enabled ??
|
||||
false;
|
||||
|
||||
@override
|
||||
State<RemoteFocusAdapter> createState() => _RemoteFocusAdapterState();
|
||||
}
|
||||
@@ -260,12 +271,25 @@ class _RemoteFocusAdapterState extends State<RemoteFocusAdapter> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
includeSemantics: false,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: widget.child,
|
||||
return _RemoteScope(
|
||||
enabled: widget.enabled,
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
includeSemantics: false,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteScope extends InheritedWidget {
|
||||
const _RemoteScope({required this.enabled, required super.child});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_RemoteScope oldWidget) =>
|
||||
enabled != oldWidget.enabled;
|
||||
}
|
||||
|
||||
+19
-8
@@ -244,8 +244,11 @@ class RenderGrid extends RenderBox
|
||||
childParentData,
|
||||
crossAxisCount,
|
||||
);
|
||||
final childCrossAxisExtent =
|
||||
stride * crossAxisCellCount - crossAxisSpacing;
|
||||
final childCrossAxisExtent = _spannedExtent(
|
||||
stride,
|
||||
crossAxisCellCount,
|
||||
crossAxisSpacing,
|
||||
);
|
||||
final double childMainAxisExtent;
|
||||
if (childParentData.mainAxisCellCount == null) {
|
||||
final childConstraints = mainAxis == Axis.vertical
|
||||
@@ -256,10 +259,11 @@ class RenderGrid extends RenderBox
|
||||
? child.size.height
|
||||
: child.size.width;
|
||||
} else {
|
||||
childMainAxisExtent =
|
||||
(this.mainAxisExtent ?? stride) *
|
||||
childParentData.mainAxisCellCount! -
|
||||
mainAxisSpacing;
|
||||
childMainAxisExtent = _spannedExtent(
|
||||
this.mainAxisExtent ?? stride,
|
||||
childParentData.mainAxisCellCount!,
|
||||
mainAxisSpacing,
|
||||
);
|
||||
final childSize = mainAxis == Axis.vertical
|
||||
? Size(childCrossAxisExtent, childMainAxisExtent)
|
||||
: Size(childMainAxisExtent, childCrossAxisExtent);
|
||||
@@ -313,8 +317,11 @@ class RenderGrid extends RenderBox
|
||||
if (mainAxis == Axis.vertical && textDirection == TextDirection.rtl) {
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
final childParentData = _getParentData(children[i]);
|
||||
final childCrossAxisExtent =
|
||||
stride * crossAxisCellCounts[i] - crossAxisSpacing;
|
||||
final childCrossAxisExtent = _spannedExtent(
|
||||
stride,
|
||||
crossAxisCellCounts[i],
|
||||
crossAxisSpacing,
|
||||
);
|
||||
final offset = childParentData.offset;
|
||||
childParentData.offset = Offset(
|
||||
crossAxisExtent - offset.dx - childCrossAxisExtent,
|
||||
@@ -409,6 +416,10 @@ class GridGeometry {
|
||||
});
|
||||
}
|
||||
|
||||
double _spannedExtent(double stride, num cellCount, double spacing) {
|
||||
return math.max(0, stride * cellCount - spacing);
|
||||
}
|
||||
|
||||
double gridStride({
|
||||
required double crossAxisExtent,
|
||||
required int crossAxisCount,
|
||||
|
||||
+178
-81
@@ -1,9 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/cache.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/database/database.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -11,13 +9,30 @@ import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
const _maxCachedIcons = 64;
|
||||
import 'route_motion_hold.dart';
|
||||
import 'text.dart';
|
||||
|
||||
const _maxCachedIconBytes = 16 << 20;
|
||||
|
||||
// The precache and the widget must resolve equal providers to share one
|
||||
// ImageCache entry; 192 physical pixels cover a 48 dp icon box at 4x.
|
||||
const _iconDecodeExtent = 192;
|
||||
|
||||
ImageProvider _iconImage(Uint8List bytes) {
|
||||
return ResizeImage(
|
||||
MemoryImage(bytes),
|
||||
width: _iconDecodeExtent,
|
||||
height: _iconDecodeExtent,
|
||||
policy: ResizeImagePolicy.fit,
|
||||
);
|
||||
}
|
||||
|
||||
class _IconBytesCache {
|
||||
_IconBytesCache(this.capacity);
|
||||
_IconBytesCache(this.maxBytes);
|
||||
|
||||
final int capacity;
|
||||
final int maxBytes;
|
||||
final _entries = <String, Uint8List?>{};
|
||||
var _bytes = 0;
|
||||
|
||||
bool contains(String key) => _entries.containsKey(key);
|
||||
|
||||
@@ -29,18 +44,25 @@ class _IconBytesCache {
|
||||
}
|
||||
|
||||
void operator []=(String key, Uint8List? value) {
|
||||
_entries.remove(key);
|
||||
if (_entries.length >= capacity) {
|
||||
_entries.remove(_entries.keys.first);
|
||||
remove(key);
|
||||
final size = value?.lengthInBytes ?? 0;
|
||||
if (size > maxBytes) {
|
||||
return;
|
||||
}
|
||||
_entries[key] = value;
|
||||
_bytes += size;
|
||||
while (_bytes > maxBytes) {
|
||||
remove(_entries.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(String key) => _entries.remove(key);
|
||||
void remove(String key) {
|
||||
_bytes -= _entries.remove(key)?.lengthInBytes ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
final _decodedIcons = _IconBytesCache(_maxCachedIcons);
|
||||
final _remoteIcons = _IconBytesCache(_maxCachedIcons);
|
||||
final _decodedIcons = _IconBytesCache(_maxCachedIconBytes);
|
||||
final _remoteIcons = _IconBytesCache(_maxCachedIconBytes);
|
||||
|
||||
Uint8List? _decodeIcon(String src) {
|
||||
if (!src.contains('base64,')) {
|
||||
@@ -55,10 +77,13 @@ Uint8List? _decodeIcon(String src) {
|
||||
class CommonTargetIcon extends StatelessWidget {
|
||||
final String src;
|
||||
|
||||
const CommonTargetIcon({super.key, required this.src});
|
||||
/// Shown while [src] is empty, loading or unreadable.
|
||||
final Widget? fallback;
|
||||
|
||||
const CommonTargetIcon({super.key, required this.src, this.fallback});
|
||||
|
||||
Widget _defaultIcon() {
|
||||
return const GlyphIcon(AppGlyphs.target);
|
||||
return fallback ?? const _ImagePlaceholder();
|
||||
}
|
||||
|
||||
Widget _buildIcon() {
|
||||
@@ -68,7 +93,11 @@ class CommonTargetIcon extends StatelessWidget {
|
||||
|
||||
final base64 = _decodeIcon(src);
|
||||
if (base64 != null) {
|
||||
return _BytesImage(bytes: base64, isSvg: false, fallback: _defaultIcon());
|
||||
return _BytesImage(
|
||||
bytes: base64,
|
||||
isSvg: src.isSvg,
|
||||
fallback: _defaultIcon(),
|
||||
);
|
||||
}
|
||||
|
||||
return ImageCacheWidget(src: src, defaultWidget: _defaultIcon());
|
||||
@@ -80,6 +109,72 @@ class CommonTargetIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ImagePlaceholder extends StatelessWidget {
|
||||
const _ImagePlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = IconTheme.of(context).size ?? 24;
|
||||
final colorScheme = context.colorScheme;
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Center(
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.photos,
|
||||
size: size * 0.7,
|
||||
color: Color.alphaBlend(
|
||||
colorScheme.onSurfaceVariant.opacity50,
|
||||
colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands for a proxy group without an icon: the first letter or digit of
|
||||
/// its name, past any leading emoji the name already shows.
|
||||
class GroupMonogram extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
const GroupMonogram(this.name, {super.key});
|
||||
|
||||
static final _letter = RegExp(r'[\p{L}\p{N}]', unicode: true);
|
||||
|
||||
String get _mark {
|
||||
final letter = _letter.firstMatch(name)?.group(0);
|
||||
if (letter != null) {
|
||||
return letter.toUpperCase();
|
||||
}
|
||||
return name.characters.firstOrNull ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final size = iconTheme.size ?? 24;
|
||||
final mark = _mark;
|
||||
if (mark.isEmpty) {
|
||||
return const _ImagePlaceholder();
|
||||
}
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Center(
|
||||
child: EmojiText(
|
||||
mark,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.62,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: iconTheme.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BytesImage extends StatelessWidget {
|
||||
const _BytesImage({
|
||||
required this.bytes,
|
||||
@@ -102,38 +197,47 @@ class _BytesImage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return isSvg
|
||||
? SvgPicture.memory(bytes, errorBuilder: (_, _, _) => _buildFallback())
|
||||
: Image.memory(
|
||||
bytes,
|
||||
: Image(
|
||||
image: _iconImage(bytes),
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _buildFallback(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _cacheMange = DefaultCacheManager();
|
||||
final _cacheManager = CacheManager(
|
||||
Config(
|
||||
DefaultCacheManager.key,
|
||||
fileService: HttpFileService()..concurrentFetches = maxConcurrentIconLoads,
|
||||
),
|
||||
);
|
||||
|
||||
Stream<Uint8List> _readCachedIcon(String src) {
|
||||
return _cacheManager
|
||||
.getFileStreamV2(src)
|
||||
.asyncMap((data) => data.file.readAsBytes());
|
||||
}
|
||||
|
||||
var _readRemoteIcon = _readCachedIcon;
|
||||
|
||||
@visibleForTesting
|
||||
set readRemoteIcon(Stream<Uint8List> Function(String src)? value) {
|
||||
_readRemoteIcon = value ?? _readCachedIcon;
|
||||
}
|
||||
|
||||
Stream<Uint8List> _loadRemoteIcon(String src) {
|
||||
return _cacheMange
|
||||
.getFileStreamV2(
|
||||
src,
|
||||
onRemoteNewLoaded: () {
|
||||
commonPrint.log('The icon has been recorded: $src');
|
||||
database.iconRecordsDao.putIfAbsent(src);
|
||||
},
|
||||
)
|
||||
.asyncMap((data) => data.file.readAsBytes())
|
||||
.map((bytes) {
|
||||
final current = _remoteIcons[src];
|
||||
final next = current != null && listEquals(current, bytes)
|
||||
? current
|
||||
: bytes;
|
||||
return _remoteIcons[src] = next;
|
||||
});
|
||||
return _readRemoteIcon(src).map((bytes) {
|
||||
final current = _remoteIcons[src];
|
||||
final next = current != null && listEquals(current, bytes)
|
||||
? current
|
||||
: bytes;
|
||||
return _remoteIcons[src] = next;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _decodeAhead(Uint8List bytes) {
|
||||
final completer = Completer<void>();
|
||||
final stream = MemoryImage(bytes).resolve(ImageConfiguration.empty);
|
||||
final stream = _iconImage(bytes).resolve(ImageConfiguration.empty);
|
||||
late final ImageStreamListener listener;
|
||||
void finish() {
|
||||
stream.removeListener(listener);
|
||||
@@ -185,23 +289,30 @@ class ImageCacheWidget extends StatefulWidget {
|
||||
State<ImageCacheWidget> createState() => _ImageCacheWidgetState();
|
||||
}
|
||||
|
||||
class _ImageCacheWidgetState extends State<ImageCacheWidget> {
|
||||
class _ImageCacheWidgetState extends State<ImageCacheWidget>
|
||||
with RouteSettledMixin<ImageCacheWidget> {
|
||||
late final ValueNotifier<Uint8List?> _bytesNotifier;
|
||||
StreamSubscription? _streamSubscription;
|
||||
var _loadEnded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bytesNotifier = ValueNotifier(_remoteIcons[widget.src]);
|
||||
_getImageFormCache();
|
||||
}
|
||||
|
||||
@override
|
||||
void didSettleRoute() => _getImageFormCache();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ImageCacheWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.src != widget.src) {
|
||||
_bytesNotifier.value = _remoteIcons[widget.src];
|
||||
_getImageFormCache();
|
||||
_loadEnded = false;
|
||||
if (routeSettled) {
|
||||
_getImageFormCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +332,11 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
|
||||
onError: (Object error) {
|
||||
commonPrint.log('Failed to read icon $src: $error');
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted && _bytesNotifier.value == null) {
|
||||
setState(() => _loadEnded = true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,9 +351,11 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Uint8List?>(
|
||||
valueListenable: _bytesNotifier,
|
||||
builder: (_, bytes, _) {
|
||||
builder: (context, bytes, _) {
|
||||
if (bytes == null) {
|
||||
return widget.defaultWidget;
|
||||
return _loadEnded || widget.src.isEmpty
|
||||
? widget.defaultWidget
|
||||
: SizedBox.square(dimension: IconTheme.of(context).size ?? 24);
|
||||
}
|
||||
return _BytesImage(
|
||||
bytes: bytes,
|
||||
@@ -266,39 +384,43 @@ class PackageIcon extends StatefulWidget {
|
||||
State<PackageIcon> createState() => _PackageIconState();
|
||||
}
|
||||
|
||||
class _PackageIconState extends State<PackageIcon> {
|
||||
class _PackageIconState extends State<PackageIcon>
|
||||
with RouteSettledMixin<PackageIcon> {
|
||||
ImageProvider? _icon;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIcon();
|
||||
_showCachedIcon();
|
||||
}
|
||||
|
||||
@override
|
||||
void didSettleRoute() => _loadIcon();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PackageIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.packageName != widget.packageName) {
|
||||
_loadIcon();
|
||||
_showCachedIcon();
|
||||
if (routeSettled) {
|
||||
_loadIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showCachedIcon() {
|
||||
_generation++;
|
||||
_icon = app?.getCachedPackageIcon(widget.packageName);
|
||||
}
|
||||
|
||||
void _loadIcon() {
|
||||
final generation = ++_generation;
|
||||
final packageName = widget.packageName;
|
||||
final currentApp = app;
|
||||
if (currentApp == null) {
|
||||
_icon = null;
|
||||
return;
|
||||
}
|
||||
if (currentApp.hasPackageIcon(packageName)) {
|
||||
_icon = currentApp.getCachedPackageIcon(packageName);
|
||||
return;
|
||||
}
|
||||
_icon = null;
|
||||
currentApp.getPackageIcon(packageName).then((icon) {
|
||||
if (!mounted || generation != _generation || icon == null) {
|
||||
final generation = _generation;
|
||||
app?.getPackageIcon(widget.packageName).then((icon) {
|
||||
if (!mounted ||
|
||||
generation != _generation ||
|
||||
icon == null ||
|
||||
icon == _icon) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -322,28 +444,3 @@ class _PackageIconState extends State<PackageIcon> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommonImage extends StatelessWidget {
|
||||
final File data;
|
||||
final bool isSvg;
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
)?
|
||||
errorBuilder;
|
||||
|
||||
const CommonImage({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.errorBuilder,
|
||||
this.isSvg = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return isSvg
|
||||
? SvgPicture.file(data, errorBuilder: errorBuilder)
|
||||
: Image.file(data, gaplessPlayback: true, errorBuilder: errorBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-32
@@ -1,9 +1,7 @@
|
||||
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';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class PageActivityScope extends InheritedWidget {
|
||||
final bool isActive;
|
||||
@@ -158,10 +156,12 @@ class ItemPositionProvider extends InheritedWidget {
|
||||
|
||||
class ProxyDecoratorProvider extends InheritedWidget {
|
||||
final bool isProxyDecorator;
|
||||
final double lift;
|
||||
|
||||
const ProxyDecoratorProvider({
|
||||
super.key,
|
||||
required this.isProxyDecorator,
|
||||
required this.lift,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
@@ -171,28 +171,34 @@ class ProxyDecoratorProvider extends InheritedWidget {
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ProxyDecoratorProvider oldWidget) =>
|
||||
isProxyDecorator != oldWidget.isProxyDecorator;
|
||||
isProxyDecorator != oldWidget.isProxyDecorator || lift != oldWidget.lift;
|
||||
}
|
||||
|
||||
class SheetProvider<T> extends InheritedWidget {
|
||||
final SheetType type;
|
||||
final void Function([T? result])? nestedNavigatorPop;
|
||||
|
||||
/// The close button's way out of the sheet; [nestedNavigatorPop] only pops.
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const SheetProvider({
|
||||
super.key,
|
||||
required super.child,
|
||||
required this.type,
|
||||
this.nestedNavigatorPop,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
SheetProvider copyWith({
|
||||
SheetType? type,
|
||||
void Function([T? result])? nestedNavigatorPop,
|
||||
VoidCallback? onClose,
|
||||
required Widget child,
|
||||
}) {
|
||||
return SheetProvider<T>(
|
||||
type: type ?? this.type,
|
||||
nestedNavigatorPop: nestedNavigatorPop ?? this.nestedNavigatorPop,
|
||||
onClose: onClose ?? this.onClose,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -207,16 +213,6 @@ class SheetProvider<T> extends InheritedWidget {
|
||||
nestedNavigatorPop != oldWidget.nestedNavigatorPop;
|
||||
}
|
||||
|
||||
extension SheetHeightExt on WidgetRef {
|
||||
double sheetHeight(BuildContext context, double factor) {
|
||||
final viewHeight = watch(viewHeightProvider);
|
||||
if (SheetProvider.of(context)?.type != SheetType.bottomSheet) {
|
||||
return double.maxFinite;
|
||||
}
|
||||
return viewHeight * factor;
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileIdProvider extends InheritedWidget {
|
||||
final int profileId;
|
||||
|
||||
@@ -286,11 +282,31 @@ class SheetDismissScope extends InheritedWidget {
|
||||
handler != oldWidget.handler;
|
||||
}
|
||||
|
||||
class SheetStackScope extends InheritedWidget {
|
||||
final ValueNotifier<bool> surfaceClaimed;
|
||||
|
||||
const SheetStackScope({
|
||||
super.key,
|
||||
required this.surfaceClaimed,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ValueNotifier<bool>? of(BuildContext context) {
|
||||
return context
|
||||
.getInheritedWidgetOfExactType<SheetStackScope>()
|
||||
?.surfaceClaimed;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetStackScope oldWidget) =>
|
||||
surfaceClaimed != oldWidget.surfaceClaimed;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
final SheetScrollControllerFactory createController;
|
||||
|
||||
const SheetScrollScope({
|
||||
super.key,
|
||||
@@ -298,7 +314,7 @@ class SheetScrollScope extends InheritedWidget {
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ScrollController Function()? of(BuildContext context) {
|
||||
static SheetScrollControllerFactory? of(BuildContext context) {
|
||||
return context
|
||||
.getInheritedWidgetOfExactType<SheetScrollScope>()
|
||||
?.createController;
|
||||
@@ -309,23 +325,16 @@ class SheetScrollScope extends InheritedWidget {
|
||||
createController != oldWidget.createController;
|
||||
}
|
||||
|
||||
/// Whether a sheet is animating to a detent, which resizes its content.
|
||||
class SheetSettlingScope extends InheritedWidget {
|
||||
final ValueListenable<bool> settling;
|
||||
typedef SheetScrollControllerFactory =
|
||||
ScrollController Function({double initialScrollOffset});
|
||||
|
||||
const SheetSettlingScope({
|
||||
super.key,
|
||||
required this.settling,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ValueListenable<bool>? of(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<SheetSettlingScope>()
|
||||
?.settling;
|
||||
ScrollController sheetScrollController(
|
||||
BuildContext context, {
|
||||
double initialScrollOffset = 0.0,
|
||||
}) {
|
||||
final createController = SheetScrollScope.of(context);
|
||||
if (createController == null) {
|
||||
return ScrollController(initialScrollOffset: initialScrollOffset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SheetSettlingScope oldWidget) =>
|
||||
settling != oldWidget.settling;
|
||||
return createController(initialScrollOffset: initialScrollOffset);
|
||||
}
|
||||
|
||||
+260
-96
@@ -100,21 +100,34 @@ class CommonCheckBox extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
typedef NamedUrl = ({String label, String url, String ageSecretKey});
|
||||
|
||||
/// The url below the name holds the focus, as the one field that must be filled.
|
||||
/// Pops a list so a single url and a batch return through the same path.
|
||||
class NamedUrlDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String label;
|
||||
final String url;
|
||||
final String? urlLabel;
|
||||
final String? batchTip;
|
||||
final FormFieldValidator<String>? labelValidator;
|
||||
final FormFieldValidator<String>? urlValidator;
|
||||
final bool batch;
|
||||
final Set<String> existingUrls;
|
||||
final bool withAgeSecretKey;
|
||||
|
||||
const NamedUrlDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.label = '',
|
||||
this.url = '',
|
||||
this.urlLabel,
|
||||
this.batchTip,
|
||||
this.labelValidator,
|
||||
this.urlValidator,
|
||||
this.batch = false,
|
||||
this.existingUrls = const {},
|
||||
this.withAgeSecretKey = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -124,8 +137,37 @@ class NamedUrlDialog extends StatefulWidget {
|
||||
class _NamedUrlDialogState extends State<NamedUrlDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _urlFocusNode = FocusNode();
|
||||
final _ageSecretKeyFocusNode = FocusNode();
|
||||
late final TextEditingController _labelController;
|
||||
late final TextEditingController _urlController;
|
||||
final _ageSecretKeyController = TextEditingController();
|
||||
final _batchController = TextEditingController();
|
||||
bool _isBatch = false;
|
||||
ParsedInput<String>? _parsed;
|
||||
|
||||
bool get _canSubmit {
|
||||
if (!_isBatch) {
|
||||
return true;
|
||||
}
|
||||
final parsed = _parsed;
|
||||
return parsed != null && parsed.isValid && parsed.entries.isNotEmpty;
|
||||
}
|
||||
|
||||
String get _urlLabel => widget.urlLabel ?? context.appLocalizations.url;
|
||||
|
||||
BatchInput<String> get _batchInput {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return BatchInput(
|
||||
label: _urlLabel,
|
||||
formatTip: widget.batchTip ?? appLocalizations.batchUrlInputTip,
|
||||
parse: (text) => parseUrlInput(
|
||||
text,
|
||||
isValid: (url) => _validateBatchUrl(url) == null,
|
||||
existing: widget.existingUrls,
|
||||
),
|
||||
issueMessage: (issue) => _validateBatchUrl(issue.raw)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -137,8 +179,11 @@ class _NamedUrlDialogState extends State<NamedUrlDialog> {
|
||||
@override
|
||||
void dispose() {
|
||||
_urlFocusNode.dispose();
|
||||
_ageSecretKeyFocusNode.dispose();
|
||||
_labelController.dispose();
|
||||
_urlController.dispose();
|
||||
_ageSecretKeyController.dispose();
|
||||
_batchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -150,29 +195,77 @@ class _NamedUrlDialogState extends State<NamedUrlDialog> {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final url = value?.trim() ?? '';
|
||||
if (url.isEmpty) {
|
||||
return appLocalizations.emptyTip(appLocalizations.url);
|
||||
return appLocalizations.emptyTip(_urlLabel);
|
||||
}
|
||||
if (!url.isUrl) {
|
||||
return appLocalizations.urlTip(appLocalizations.url);
|
||||
return appLocalizations.urlTip(_urlLabel);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateBatchUrl(String url) {
|
||||
if (url.length > TextInputLimits.url) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return appLocalizations.maxLengthTip(_urlLabel, TextInputLimits.url);
|
||||
}
|
||||
return _validateUrl(url);
|
||||
}
|
||||
|
||||
void _toggleBatch() {
|
||||
setState(() {
|
||||
_isBatch = !_isBatch;
|
||||
if (_isBatch && _batchController.text.isEmpty) {
|
||||
_batchController.text = _urlController.text.trim();
|
||||
}
|
||||
if (_isBatch) {
|
||||
_parsed = _batchInput.parse(_batchController.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleBatchChanged(String text) {
|
||||
setState(() {
|
||||
_parsed = _batchInput.parse(text);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (!_canSubmit) {
|
||||
return;
|
||||
}
|
||||
if (_isBatch) {
|
||||
Navigator.of(context).pop<List<NamedUrl>>([
|
||||
for (final url in _parsed!.entries)
|
||||
(label: '', url: url, ageSecretKey: ''),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (_formKey.currentState?.validate() == false) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop<({String label, String url})>((
|
||||
label: _labelController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
));
|
||||
Navigator.of(context).pop<List<NamedUrl>>([
|
||||
(
|
||||
label: _labelController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
ageSecretKey: _ageSecretKeyController.text.trim(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return CommonDialog(
|
||||
title: widget.title,
|
||||
title: _isBatch ? appLocalizations.batchImport : widget.title,
|
||||
trailing: widget.batch
|
||||
? _BatchToggle(
|
||||
isBatch: _isBatch,
|
||||
tooltip: _isBatch
|
||||
? appLocalizations.singleImport
|
||||
: appLocalizations.batchImport,
|
||||
onPressed: _toggleBatch,
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -181,46 +274,77 @@ class _NamedUrlDialogState extends State<NamedUrlDialog> {
|
||||
child: Text(appLocalizations.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _handleSubmit,
|
||||
onPressed: _canSubmit ? _handleSubmit : null,
|
||||
child: Text(appLocalizations.submit),
|
||||
),
|
||||
],
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Wrap(
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _labelController,
|
||||
validator: widget.labelValidator,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
|
||||
onFieldSubmitted: (_) {
|
||||
_urlFocusNode.requestFocus();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: appLocalizations.name,
|
||||
helperText: appLocalizations.optional,
|
||||
),
|
||||
child: _isBatch
|
||||
? _BatchInputField(
|
||||
batch: _batchInput,
|
||||
controller: _batchController,
|
||||
parsed: _parsed,
|
||||
onChanged: _handleBatchChanged,
|
||||
)
|
||||
: _buildForm(appLocalizations),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(AppLocalizations appLocalizations) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Wrap(
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _labelController,
|
||||
validator: widget.labelValidator,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
|
||||
onFieldSubmitted: (_) {
|
||||
_urlFocusNode.requestFocus();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: appLocalizations.name,
|
||||
helperText: appLocalizations.optional,
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
focusNode: _urlFocusNode,
|
||||
controller: _urlController,
|
||||
validator: _validateUrl,
|
||||
keyboardType: TextInputType.url,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
textInputAction: widget.withAgeSecretKey
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
|
||||
onFieldSubmitted: (_) {
|
||||
if (widget.withAgeSecretKey) {
|
||||
_ageSecretKeyFocusNode.requestFocus();
|
||||
} else {
|
||||
_handleSubmit();
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(labelText: _urlLabel),
|
||||
),
|
||||
if (widget.withAgeSecretKey)
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
focusNode: _urlFocusNode,
|
||||
controller: _urlController,
|
||||
validator: _validateUrl,
|
||||
keyboardType: TextInputType.url,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
focusNode: _ageSecretKeyFocusNode,
|
||||
controller: _ageSecretKeyController,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
|
||||
onFieldSubmitted: (_) {
|
||||
_handleSubmit();
|
||||
},
|
||||
decoration: InputDecoration(labelText: appLocalizations.url),
|
||||
decoration: InputDecoration(
|
||||
labelText: appLocalizations.ageSecretKey,
|
||||
helperText: appLocalizations.optional,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -391,8 +515,6 @@ class EntryDialog<T> extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EntryDialogState<T> extends State<EntryDialog<T>> {
|
||||
static const _maxShownIssues = 3;
|
||||
|
||||
TextEditingController? _keyController;
|
||||
late final TextEditingController _valueController;
|
||||
final _batchController = TextEditingController();
|
||||
@@ -463,52 +585,6 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _batchErrorText(AppLocalizations appLocalizations) {
|
||||
final parsed = _parsed;
|
||||
if (parsed == null || parsed.isValid) {
|
||||
return null;
|
||||
}
|
||||
return parsed.issues
|
||||
.take(_maxShownIssues)
|
||||
.map(
|
||||
(issue) => appLocalizations.lineIssueTip(
|
||||
issue.line,
|
||||
widget.batch!.issueMessage(issue),
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
String _batchHelperText(AppLocalizations appLocalizations) {
|
||||
final parsed = _parsed;
|
||||
if (parsed == null || _batchController.text.trim().isEmpty) {
|
||||
return widget.batch!.formatTip;
|
||||
}
|
||||
return appLocalizations.batchPreviewTip(
|
||||
parsed.entries.length,
|
||||
parsed.skippedExisting,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatchField(AppLocalizations appLocalizations) {
|
||||
return TextField(
|
||||
controller: _batchController,
|
||||
autofocus: true,
|
||||
minLines: 4,
|
||||
maxLines: 10,
|
||||
keyboardType: TextInputType.multiline,
|
||||
onChanged: _handleBatchChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.batch!.label,
|
||||
alignLabelWithHint: true,
|
||||
helperText: _batchHelperText(appLocalizations),
|
||||
helperMaxLines: 2,
|
||||
errorText: _batchErrorText(appLocalizations),
|
||||
errorMaxLines: _maxShownIssues + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(AppLocalizations appLocalizations) {
|
||||
return Form(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
@@ -578,16 +654,12 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
|
||||
title: _isBatch ? appLocalizations.batchAdd : widget.title,
|
||||
trailing: widget.batch == null
|
||||
? null
|
||||
: CommonMinIconButtonTheme(
|
||||
child: IconButton(
|
||||
tooltip: _isBatch
|
||||
? appLocalizations.singleAdd
|
||||
: appLocalizations.batchAdd,
|
||||
onPressed: _toggleBatch,
|
||||
icon: GlyphIcon(
|
||||
_isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd,
|
||||
),
|
||||
),
|
||||
: _BatchToggle(
|
||||
isBatch: _isBatch,
|
||||
tooltip: _isBatch
|
||||
? appLocalizations.singleAdd
|
||||
: appLocalizations.batchAdd,
|
||||
onPressed: _toggleBatch,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -596,12 +668,104 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
|
||||
),
|
||||
],
|
||||
child: _isBatch
|
||||
? _buildBatchField(appLocalizations)
|
||||
? _BatchInputField(
|
||||
batch: widget.batch!,
|
||||
controller: _batchController,
|
||||
parsed: _parsed,
|
||||
onChanged: _handleBatchChanged,
|
||||
)
|
||||
: _buildForm(appLocalizations),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BatchToggle extends StatelessWidget {
|
||||
final bool isBatch;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _BatchToggle({
|
||||
required this.isBatch,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonMinIconButtonTheme(
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: GlyphIcon(isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BatchInputField<T> extends StatelessWidget {
|
||||
static const _maxShownIssues = 3;
|
||||
|
||||
final BatchInput<T> batch;
|
||||
final TextEditingController controller;
|
||||
final ParsedInput<T>? parsed;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _BatchInputField({
|
||||
required this.batch,
|
||||
required this.controller,
|
||||
required this.parsed,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
String? _errorText(AppLocalizations appLocalizations) {
|
||||
final parsed = this.parsed;
|
||||
if (parsed == null || parsed.isValid) {
|
||||
return null;
|
||||
}
|
||||
return parsed.issues
|
||||
.take(_maxShownIssues)
|
||||
.map(
|
||||
(issue) => appLocalizations.lineIssueTip(
|
||||
issue.line,
|
||||
batch.issueMessage(issue),
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
String _helperText(AppLocalizations appLocalizations) {
|
||||
final parsed = this.parsed;
|
||||
if (parsed == null || controller.text.trim().isEmpty) {
|
||||
return batch.formatTip;
|
||||
}
|
||||
return appLocalizations.batchPreviewTip(
|
||||
parsed.entries.length,
|
||||
parsed.skippedExisting,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
minLines: 4,
|
||||
maxLines: 10,
|
||||
keyboardType: TextInputType.multiline,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: batch.label,
|
||||
alignLabelWithHint: true,
|
||||
helperText: _helperText(appLocalizations),
|
||||
helperMaxLines: 2,
|
||||
errorText: _errorText(appLocalizations),
|
||||
errorMaxLines: _maxShownIssues + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NoInputBorder extends InputBorder {
|
||||
const NoInputBorder() : super(borderSide: BorderSide.none);
|
||||
|
||||
|
||||
+90
-49
@@ -1,8 +1,9 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'card.dart';
|
||||
@@ -10,7 +11,9 @@ import 'input.dart';
|
||||
import 'open_container.dart';
|
||||
import 'scaffold.dart';
|
||||
import 'sheet.dart';
|
||||
import 'sortable.dart';
|
||||
|
||||
part 'list_row_separator.dart';
|
||||
part 'list_selected.dart';
|
||||
|
||||
sealed class _ListItemAction {
|
||||
@@ -57,9 +60,8 @@ final class _OpenAction extends _ListItemAction {
|
||||
|
||||
final class _NextAction extends _ListItemAction {
|
||||
final Widget widget;
|
||||
final double? maxWidth;
|
||||
|
||||
const _NextAction({required this.widget, this.maxWidth});
|
||||
const _NextAction({required this.widget});
|
||||
}
|
||||
|
||||
final class _OptionsAction<T> extends _ListItemAction {
|
||||
@@ -133,7 +135,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = const _DefaultAction();
|
||||
|
||||
@@ -154,7 +156,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _OpenAction(
|
||||
widget: widget,
|
||||
@@ -171,7 +173,6 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 16),
|
||||
this.trailing,
|
||||
required Widget widget,
|
||||
double? maxWidth,
|
||||
this.horizontalTitleGap,
|
||||
this.dense,
|
||||
this.titleTextStyle,
|
||||
@@ -179,9 +180,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _NextAction(widget: widget, maxWidth: maxWidth),
|
||||
}) : _action = _NextAction(widget: widget),
|
||||
onTap = null;
|
||||
|
||||
ListItem.options({
|
||||
@@ -203,7 +204,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _OptionsAction<T>(
|
||||
title: dialogTitle,
|
||||
@@ -236,7 +237,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _InputAction(
|
||||
title: dialogTitle,
|
||||
@@ -265,7 +266,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _CheckboxAction(value: value, onChanged: onChanged),
|
||||
trailing = null,
|
||||
@@ -286,7 +287,7 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _ToggleAction(value: value, onChanged: onChanged),
|
||||
trailing = null,
|
||||
@@ -307,14 +308,16 @@ class ListItem<T> extends StatelessWidget {
|
||||
this.color,
|
||||
this.minTileHeight,
|
||||
this.visualDensity,
|
||||
this.minVerticalPadding = 12,
|
||||
this.minVerticalPadding = listRowVerticalPadding,
|
||||
this.tileTitleAlignment = ListTileTitleAlignment.center,
|
||||
}) : _action = _RadioAction<T>(value: value, onTap: onTap),
|
||||
leading = null,
|
||||
onTap = null;
|
||||
|
||||
Widget _buildListTile({
|
||||
Widget _buildListTile(
|
||||
BuildContext context, {
|
||||
required ItemPosition? position,
|
||||
required WidgetStatesController? rowStates,
|
||||
void Function()? onTap,
|
||||
Widget? trailing,
|
||||
Widget? leading,
|
||||
@@ -339,16 +342,17 @@ class ListItem<T> extends StatelessWidget {
|
||||
dense: dense,
|
||||
visualDensity: visualDensity,
|
||||
tileColor: color,
|
||||
titleTextStyle: titleTextStyle,
|
||||
subtitleTextStyle: subtitleTextStyle,
|
||||
titleTextStyle: titleTextStyle ?? context.listTitleStyle,
|
||||
subtitleTextStyle: subtitleTextStyle ?? context.listSubtitleStyle,
|
||||
leading: leading ?? this.leading,
|
||||
horizontalTitleGap: horizontalTitleGap,
|
||||
title: title,
|
||||
minTileHeight: minTileHeight,
|
||||
title: rowStates == null ? title : _ListRowSeparatorStart(child: title),
|
||||
minTileHeight: minTileHeight ?? listRowMinHeight,
|
||||
minVerticalPadding: minVerticalPadding,
|
||||
subtitle: subtitle,
|
||||
titleAlignment: tileTitleAlignment,
|
||||
onTap: onTap,
|
||||
statesController: rowStates,
|
||||
trailing: trailing ?? this.trailing,
|
||||
contentPadding: padding,
|
||||
);
|
||||
@@ -357,13 +361,16 @@ class ListItem<T> extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final position = ItemPositionProvider.of(context)?.position;
|
||||
final rowStates = _SeparatedListRow.statesOf(context);
|
||||
switch (_action) {
|
||||
case final _OpenAction openDelegate:
|
||||
final child = openDelegate.widget;
|
||||
final onChanged = openDelegate.onChanged;
|
||||
if (!context.isMobileView) {
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: () async {
|
||||
final result = await showExtend<dynamic>(
|
||||
context,
|
||||
@@ -375,8 +382,15 @@ class ListItem<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return OpenContainer<dynamic>(
|
||||
tappable: false,
|
||||
clipBehavior: Clip.none,
|
||||
closedBuilder: (context, action) {
|
||||
return _buildListTile(position: position, onTap: action);
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: action,
|
||||
);
|
||||
},
|
||||
onClosed: onChanged,
|
||||
openBuilder: (_, action) {
|
||||
@@ -387,11 +401,12 @@ class ListItem<T> extends StatelessWidget {
|
||||
final child = nextDelegate.widget;
|
||||
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: () {
|
||||
showExtend(
|
||||
context,
|
||||
props: ExtendProps(maxWidth: nextDelegate.maxWidth),
|
||||
builder: (_) {
|
||||
return child;
|
||||
},
|
||||
@@ -401,7 +416,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
case final _OptionsAction options:
|
||||
final optionsDelegate = options as _OptionsAction<T>;
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: () async {
|
||||
// Options are boxed so that a nullable option such as the default
|
||||
// locale stays distinct from the null a dismissed dialog returns.
|
||||
@@ -423,7 +440,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
);
|
||||
case final _InputAction inputDelegate:
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: () async {
|
||||
final value = await dialogs.showCommonDialog<String>(
|
||||
child: InputDialog(
|
||||
@@ -443,7 +462,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
);
|
||||
case final _CheckboxAction checkboxDelegate:
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: checkboxDelegate.onChanged == null
|
||||
? null
|
||||
: () {
|
||||
@@ -456,7 +477,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
);
|
||||
case final _ToggleAction toggleAction:
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: toggleAction.onChanged == null
|
||||
? null
|
||||
: () {
|
||||
@@ -470,7 +493,9 @@ class ListItem<T> extends StatelessWidget {
|
||||
case final _RadioAction radio:
|
||||
final radioDelegate = radio as _RadioAction<T>;
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: radioDelegate.onTap,
|
||||
leading: ExcludeFocus(
|
||||
child: Radio<T>(
|
||||
@@ -483,14 +508,18 @@ class ListItem<T> extends StatelessWidget {
|
||||
trailing: trailing,
|
||||
);
|
||||
case _DefaultAction():
|
||||
return _buildListTile(position: position, onTap: onTap);
|
||||
return _buildListTile(
|
||||
context,
|
||||
position: position,
|
||||
rowStates: rowStates,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ListHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subTitle;
|
||||
final List<Widget> actions;
|
||||
final EdgeInsets? padding;
|
||||
final double? space;
|
||||
@@ -498,7 +527,6 @@ class ListHeader extends StatelessWidget {
|
||||
const ListHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subTitle,
|
||||
this.padding,
|
||||
List<Widget>? actions,
|
||||
this.space,
|
||||
@@ -515,26 +543,11 @@ class ListHeader extends StatelessWidget {
|
||||
spacing: actions.isEmpty ? 0 : 12,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant.opacity80,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subTitle != null)
|
||||
Text(
|
||||
subTitle!,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.sectionHeaderStyle,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
@@ -549,6 +562,29 @@ class ListHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ListFooter extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const ListFooter({super.key, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: EdgeInsets.only(left: 16.mAp, right: 16.mAp, top: 8.mAp),
|
||||
child: Text(
|
||||
text,
|
||||
style: context.textTheme.bodySmall
|
||||
?.adjustSize(1)
|
||||
.copyWith(
|
||||
height: 18 / 13,
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> generateSection({
|
||||
String? title,
|
||||
required Iterable<Widget> items,
|
||||
@@ -556,9 +592,7 @@ List<Widget> generateSection({
|
||||
bool isFirst = false,
|
||||
bool separated = true,
|
||||
}) {
|
||||
final genItems = separated
|
||||
? items.separated(const Divider(height: 0))
|
||||
: items;
|
||||
final genItems = separated ? _separatedRows(items) : items;
|
||||
return [
|
||||
if (items.isNotEmpty && title != null)
|
||||
ListHeader(
|
||||
@@ -572,10 +606,18 @@ List<Widget> generateSection({
|
||||
];
|
||||
}
|
||||
|
||||
Iterable<Widget> _separatedRows(Iterable<Widget> items) {
|
||||
final last = items.length - 1;
|
||||
return items.mapIndexed(
|
||||
(index, item) => _SeparatedListRow(separated: index < last, child: item),
|
||||
);
|
||||
}
|
||||
|
||||
Widget generateSectionV3({
|
||||
String? title,
|
||||
required Iterable<Widget> items,
|
||||
List<Widget>? actions,
|
||||
String? footer,
|
||||
}) {
|
||||
final genItems = items.mapIndexed<Widget>(
|
||||
(index, item) => ItemPositionProvider(
|
||||
@@ -588,6 +630,7 @@ Widget generateSectionV3({
|
||||
if (items.isNotEmpty && title != null)
|
||||
ListHeader(title: title, actions: actions),
|
||||
Column(children: [...genItems]),
|
||||
if (items.isNotEmpty && footer != null) ListFooter(text: footer),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -598,9 +641,7 @@ List<Widget> generateInfoSection({
|
||||
List<Widget>? actions,
|
||||
bool separated = true,
|
||||
}) {
|
||||
final genItems = separated
|
||||
? items.separated(const Divider(height: 0))
|
||||
: items;
|
||||
final genItems = separated ? _separatedRows(items) : items;
|
||||
return [
|
||||
if (items.isNotEmpty) InfoHeader(info: info, actions: actions),
|
||||
...genItems,
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
part of 'list.dart';
|
||||
|
||||
/// Draws a row's bottom separator from where its title starts, and drops it
|
||||
/// whenever this row or the one below is highlighted, as iOS grouped lists
|
||||
/// do. The row below is found through the render tree, so a list only has to
|
||||
/// stack its rows for them to see each other.
|
||||
class _ListRowSeparator extends SingleChildRenderObjectWidget {
|
||||
final bool separated;
|
||||
final bool emphasized;
|
||||
final WidgetStatesController states;
|
||||
final double endIndent;
|
||||
|
||||
const _ListRowSeparator({
|
||||
required this.separated,
|
||||
required this.emphasized,
|
||||
required this.states,
|
||||
required this.endIndent,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
Color _color(BuildContext context) =>
|
||||
DividerTheme.of(context).color ?? context.colorScheme.outlineVariant;
|
||||
|
||||
@override
|
||||
_RenderListRowSeparator createRenderObject(BuildContext context) =>
|
||||
_RenderListRowSeparator(
|
||||
separated: separated,
|
||||
emphasized: emphasized,
|
||||
states: states,
|
||||
endIndent: endIndent,
|
||||
color: _color(context),
|
||||
textDirection: Directionality.of(context),
|
||||
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderListRowSeparator renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..separated = separated
|
||||
..emphasized = emphasized
|
||||
..states = states
|
||||
..endIndent = endIndent
|
||||
..color = _color(context)
|
||||
..textDirection = Directionality.of(context)
|
||||
..devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
// A reorder hands a row a new neighbour below without changing any of
|
||||
// its own properties.
|
||||
if (separated) {
|
||||
renderObject.markNeedsPaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of a flat section, whose [ListItem] picks up [statesOf] so the
|
||||
/// separator sees it pressed or focused.
|
||||
class _SeparatedListRow extends StatefulWidget {
|
||||
final bool separated;
|
||||
final Widget child;
|
||||
|
||||
const _SeparatedListRow({required this.separated, required this.child});
|
||||
|
||||
static WidgetStatesController? statesOf(BuildContext context) => context
|
||||
.dependOnInheritedWidgetOfExactType<_SeparatedListRowStates>()
|
||||
?.states;
|
||||
|
||||
@override
|
||||
State<_SeparatedListRow> createState() => _SeparatedListRowState();
|
||||
}
|
||||
|
||||
class _SeparatedListRowState extends State<_SeparatedListRow> {
|
||||
final _states = WidgetStatesController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_states.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SeparatedListRowStates(
|
||||
states: _states,
|
||||
child: _ListRowSeparator(
|
||||
separated: widget.separated,
|
||||
emphasized: false,
|
||||
states: _states,
|
||||
endIndent: 0,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeparatedListRowStates extends InheritedWidget {
|
||||
final WidgetStatesController states;
|
||||
|
||||
const _SeparatedListRowStates({required this.states, required super.child});
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_SeparatedListRowStates oldWidget) =>
|
||||
states != oldWidget.states;
|
||||
}
|
||||
|
||||
class _ListRowSeparatorStart extends SingleChildRenderObjectWidget {
|
||||
const _ListRowSeparatorStart({required super.child});
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderListRowSeparatorStart();
|
||||
}
|
||||
|
||||
class _RenderListRowSeparatorStart extends RenderProxyBox {
|
||||
_RenderListRowSeparator? _row;
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
var node = parent;
|
||||
while (node != null && node is! _RenderListRowSeparator) {
|
||||
node = node.parent;
|
||||
}
|
||||
_row = node as _RenderListRowSeparator?;
|
||||
_row?._start = this;
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
if (_row?._start == this) {
|
||||
_row!._start = null;
|
||||
}
|
||||
_row = null;
|
||||
super.detach();
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderListRowSeparator extends RenderProxyBox {
|
||||
_RenderListRowSeparator({
|
||||
required bool separated,
|
||||
required bool emphasized,
|
||||
required WidgetStatesController states,
|
||||
required double endIndent,
|
||||
required Color color,
|
||||
required TextDirection textDirection,
|
||||
required double devicePixelRatio,
|
||||
}) : _separated = separated,
|
||||
_emphasized = emphasized,
|
||||
_states = states,
|
||||
_endIndent = endIndent,
|
||||
_color = color,
|
||||
_textDirection = textDirection,
|
||||
_devicePixelRatio = devicePixelRatio {
|
||||
_highlighted = _resolveHighlighted();
|
||||
}
|
||||
|
||||
static const _maxSearchDepth = 32;
|
||||
|
||||
_RenderListRowSeparatorStart? _start;
|
||||
late bool _highlighted;
|
||||
double? _paintedOverhang;
|
||||
double? _settlingOverhang;
|
||||
bool _realignRequested = false;
|
||||
|
||||
bool _separated;
|
||||
|
||||
set separated(bool value) {
|
||||
if (_separated == value) {
|
||||
return;
|
||||
}
|
||||
_separated = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
bool _emphasized;
|
||||
|
||||
set emphasized(bool value) {
|
||||
if (_emphasized == value) {
|
||||
return;
|
||||
}
|
||||
_emphasized = value;
|
||||
_updateHighlighted();
|
||||
}
|
||||
|
||||
WidgetStatesController _states;
|
||||
|
||||
set states(WidgetStatesController value) {
|
||||
if (_states == value) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_states.removeListener(_updateHighlighted);
|
||||
value.addListener(_updateHighlighted);
|
||||
}
|
||||
_states = value;
|
||||
_updateHighlighted();
|
||||
}
|
||||
|
||||
double _endIndent;
|
||||
|
||||
set endIndent(double value) {
|
||||
if (_endIndent == value) {
|
||||
return;
|
||||
}
|
||||
_endIndent = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Color _color;
|
||||
|
||||
set color(Color value) {
|
||||
if (_color == value) {
|
||||
return;
|
||||
}
|
||||
_color = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
TextDirection _textDirection;
|
||||
|
||||
set textDirection(TextDirection value) {
|
||||
if (_textDirection == value) {
|
||||
return;
|
||||
}
|
||||
_textDirection = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double _devicePixelRatio;
|
||||
|
||||
set devicePixelRatio(double value) {
|
||||
if (_devicePixelRatio == value) {
|
||||
return;
|
||||
}
|
||||
_devicePixelRatio = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double get _thickness {
|
||||
final pixels = (_devicePixelRatio * 0.75).round();
|
||||
return (pixels < 1 ? 1 : pixels) / _devicePixelRatio;
|
||||
}
|
||||
|
||||
bool _resolveHighlighted() {
|
||||
if (_emphasized) {
|
||||
return true;
|
||||
}
|
||||
final states = _states.value;
|
||||
// InkWell only paints the focus overlay in traditional highlight mode.
|
||||
return states.contains(WidgetState.pressed) ||
|
||||
(states.contains(WidgetState.focused) &&
|
||||
FocusManager.instance.highlightMode ==
|
||||
FocusHighlightMode.traditional);
|
||||
}
|
||||
|
||||
void _updateHighlighted() {
|
||||
final highlighted = _resolveHighlighted();
|
||||
if (_highlighted == highlighted) {
|
||||
return;
|
||||
}
|
||||
_highlighted = highlighted;
|
||||
markNeedsPaint();
|
||||
_neighbour(below: false)?.markNeedsPaint();
|
||||
}
|
||||
|
||||
_RenderListRowSeparator? _neighbour({required bool below}) {
|
||||
RenderObject node = this;
|
||||
for (
|
||||
var depth = 0;
|
||||
node.parentData is! ContainerParentDataMixin<RenderObject>;
|
||||
depth++
|
||||
) {
|
||||
final parent = node.parent;
|
||||
if (parent == null || depth == _maxSearchDepth) {
|
||||
return null;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
final siblings = node.parentData! as ContainerParentDataMixin<RenderObject>;
|
||||
RenderObject? candidate = below
|
||||
? siblings.nextSibling
|
||||
: siblings.previousSibling;
|
||||
for (var depth = 0; candidate != null; depth++) {
|
||||
if (candidate is _RenderListRowSeparator) {
|
||||
return candidate;
|
||||
}
|
||||
if (depth == _maxSearchDepth) {
|
||||
return null;
|
||||
}
|
||||
RenderObject? onlyChild;
|
||||
var childCount = 0;
|
||||
candidate.visitChildren((child) {
|
||||
onlyChild = child;
|
||||
childCount++;
|
||||
});
|
||||
candidate = childCount == 1 ? onlyChild : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_states.addListener(_updateHighlighted);
|
||||
_highlighted = _resolveHighlighted();
|
||||
_PixelRealignment.watch(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_PixelRealignment.unwatch(this);
|
||||
_states.removeListener(_updateHighlighted);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
/// Null while an ancestor has stopped painting the row, such as a sliver
|
||||
/// that moved it into its keep-alive bucket: its paint transform is then
|
||||
/// all zeros, which [localToGlobal] turns into NaN.
|
||||
double? _pixelOverhang() {
|
||||
final deviceBottom =
|
||||
localToGlobal(Offset(0, size.height)).dy * _devicePixelRatio;
|
||||
if (!deviceBottom.isFinite) {
|
||||
return null;
|
||||
}
|
||||
return deviceBottom - (deviceBottom + 1e-3).floorToDouble();
|
||||
}
|
||||
|
||||
/// Returns whether the row needs another frame, either to see it stop
|
||||
/// moving or to repaint: this runs after the frame has painted, when a
|
||||
/// [markNeedsPaint] no longer schedules one.
|
||||
bool _realign() {
|
||||
final painted = _paintedOverhang;
|
||||
if (painted == null) {
|
||||
return false;
|
||||
}
|
||||
final overhang = _pixelOverhang();
|
||||
if (overhang == null) {
|
||||
return false;
|
||||
}
|
||||
bool same(double? other) =>
|
||||
other != null && (overhang - other).abs() < 0.01;
|
||||
if (same(painted)) {
|
||||
_settlingOverhang = null;
|
||||
return false;
|
||||
}
|
||||
if (!same(_settlingOverhang)) {
|
||||
_settlingOverhang = overhang;
|
||||
return true;
|
||||
}
|
||||
if (_realignRequested) {
|
||||
return false;
|
||||
}
|
||||
_realignRequested = true;
|
||||
markNeedsPaint();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
// A lazily built row arrives below a row that has already painted.
|
||||
if (_highlighted) {
|
||||
_neighbour(below: false)?.markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
super.paint(context, offset);
|
||||
_paintedOverhang = null;
|
||||
_realignRequested = false;
|
||||
if (!_separated || _highlighted) {
|
||||
return;
|
||||
}
|
||||
if (_neighbour(below: true)?._highlighted ?? false) {
|
||||
return;
|
||||
}
|
||||
final start = _start;
|
||||
final title = start != null && start.hasSize
|
||||
? MatrixUtils.transformRect(
|
||||
start.getTransformTo(this),
|
||||
Offset.zero & start.size,
|
||||
)
|
||||
: null;
|
||||
final (left, right) = switch (_textDirection) {
|
||||
TextDirection.ltr => (title?.left ?? 0.0, size.width - _endIndent),
|
||||
TextDirection.rtl => (_endIndent, title?.right ?? size.width),
|
||||
};
|
||||
// Layout is not snapped to device pixels, and a line straddling pixel rows
|
||||
// renders with faint, blurred edges.
|
||||
final overhang = _pixelOverhang() ?? 0;
|
||||
_paintedOverhang = overhang;
|
||||
final bottom = size.height - overhang / _devicePixelRatio;
|
||||
context.canvas.drawRect(
|
||||
Rect.fromLTRB(left, bottom - _thickness, right, bottom).shift(offset),
|
||||
Paint()..color = _color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolling, sheets and transitions move a painted row without repainting
|
||||
/// it, which can leave its line between device pixels. Once nothing is moving
|
||||
/// any more, a row whose line drifted off the pixel grid paints again.
|
||||
abstract final class _PixelRealignment {
|
||||
static final _rows = <_RenderListRowSeparator>{};
|
||||
static var _listening = false;
|
||||
|
||||
static void watch(_RenderListRowSeparator row) {
|
||||
_rows.add(row);
|
||||
if (!_listening) {
|
||||
_listening = true;
|
||||
SchedulerBinding.instance.addPersistentFrameCallback(_check);
|
||||
}
|
||||
}
|
||||
|
||||
static void unwatch(_RenderListRowSeparator row) => _rows.remove(row);
|
||||
|
||||
static void _check(Duration _) {
|
||||
if (_rows.isEmpty || SchedulerBinding.instance.hasScheduledFrame) {
|
||||
return;
|
||||
}
|
||||
var needsFrame = false;
|
||||
for (final row in _rows) {
|
||||
needsFrame = row._realign() || needsFrame;
|
||||
}
|
||||
if (needsFrame) {
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,9 @@ class CommonSelectedListItem extends StatelessWidget {
|
||||
onPressed();
|
||||
},
|
||||
child: ListTile(
|
||||
minTileHeight: 32 + globalState.measure.bodyMediumHeight,
|
||||
minVerticalPadding: 12,
|
||||
minTileHeight: listRowMinHeight,
|
||||
minVerticalPadding: listRowVerticalPadding,
|
||||
titleTextStyle: context.listTitleStyle,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
trailing: SizedBox(
|
||||
width: 24,
|
||||
@@ -57,7 +58,7 @@ class CommonSelectedListItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class DecorationListItem extends StatelessWidget {
|
||||
class DecorationListItem extends StatefulWidget {
|
||||
final Widget title;
|
||||
final Widget? subtitle;
|
||||
final Widget? leading;
|
||||
@@ -83,10 +84,25 @@ class DecorationListItem extends StatelessWidget {
|
||||
this.invalid = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DecorationListItem> createState() => _DecorationListItemState();
|
||||
}
|
||||
|
||||
class _DecorationListItemState extends State<DecorationListItem> {
|
||||
final _states = WidgetStatesController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_states.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final proxyDecorator =
|
||||
ProxyDecoratorProvider.of(context)?.isProxyDecorator ?? false;
|
||||
final proxy = ProxyDecoratorProvider.of(context);
|
||||
final proxyDecorator = proxy?.isProxyDecorator ?? false;
|
||||
final sortable = SortableItem.maybeOf(context);
|
||||
final sorting = sortable?.sorting ?? false;
|
||||
final position = ItemPositionProvider.of(context)?.position;
|
||||
final isStart = [
|
||||
ItemPosition.start,
|
||||
@@ -100,44 +116,58 @@ class DecorationListItem extends StatelessWidget {
|
||||
top: isStart ? AppCorner.xl : AppCorner.none,
|
||||
bottom: isEnd ? AppCorner.xl : AppCorner.none,
|
||||
);
|
||||
return CommonCard(
|
||||
shape: proxyDecorator == true
|
||||
? LinearBorder.none
|
||||
: AppShape.of(borderRadius),
|
||||
isError: invalid,
|
||||
isSelected: isSelected,
|
||||
padding: EdgeInsets.zero,
|
||||
type: CommonCardType.filled,
|
||||
onPressed: proxyDecorator ? null : onPressed,
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final isInfinite = constraints.maxHeight >= double.infinity;
|
||||
final tile = ListTile(
|
||||
leading: leading,
|
||||
contentPadding:
|
||||
contentPadding ?? const EdgeInsets.only(right: 16, left: 16),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
minVerticalPadding: minVerticalPadding ?? 6,
|
||||
minTileHeight: 54,
|
||||
horizontalTitleGap: horizontalTitleGap,
|
||||
trailing: trailing,
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: isInfinite ? FlexFit.loose : FlexFit.tight,
|
||||
child: tile,
|
||||
),
|
||||
if (!invalid && proxyDecorator != true && !isEnd)
|
||||
const Divider(height: 0, indent: 14, endIndent: 14),
|
||||
],
|
||||
);
|
||||
},
|
||||
final row = _ListRowSeparator(
|
||||
separated: !proxyDecorator && !isEnd,
|
||||
emphasized: (widget.isSelected ?? false) || widget.invalid,
|
||||
states: _states,
|
||||
endIndent: 14,
|
||||
child: CommonCard(
|
||||
shape: AppShape.of(borderRadius),
|
||||
isError: widget.invalid,
|
||||
isSelected: widget.isSelected,
|
||||
padding: EdgeInsets.zero,
|
||||
type: CommonCardType.filled,
|
||||
statesController: _states,
|
||||
inert: sorting,
|
||||
onPressed: proxyDecorator ? null : widget.onPressed,
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final tile = ListTile(
|
||||
leading: widget.leading == null
|
||||
? null
|
||||
: IgnorePointer(ignoring: sorting, child: widget.leading),
|
||||
contentPadding:
|
||||
widget.contentPadding ??
|
||||
const EdgeInsets.only(right: 16, left: 16),
|
||||
title: _ListRowSeparatorStart(child: widget.title),
|
||||
subtitle: widget.subtitle,
|
||||
titleTextStyle: context.listTitleStyle,
|
||||
subtitleTextStyle: context.listSubtitleStyle,
|
||||
minVerticalPadding:
|
||||
widget.minVerticalPadding ?? listRowVerticalPadding,
|
||||
minTileHeight: listRowMinHeight,
|
||||
horizontalTitleGap: widget.horizontalTitleGap,
|
||||
trailing: sortable == null
|
||||
? widget.trailing
|
||||
: SortableTrailing(item: sortable, child: widget.trailing),
|
||||
);
|
||||
if (constraints.maxHeight >= double.infinity) {
|
||||
return tile;
|
||||
}
|
||||
return SizedBox(height: constraints.maxHeight, child: tile);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!proxyDecorator) {
|
||||
return row;
|
||||
}
|
||||
// A change of the card's shape eases in over the button's own animation
|
||||
// duration, which would trail the lift; the clip follows it frame by frame.
|
||||
return ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(AppCorner.xl * proxy!.lift),
|
||||
child: row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/context.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
@@ -9,6 +11,37 @@ import 'package:flutter/physics.dart';
|
||||
|
||||
enum LoadingIndicatorM3EVariant { defaultStyle, contained }
|
||||
|
||||
/// Takes no room while idle, so the row's title keeps the width.
|
||||
class TrailingLoading extends StatelessWidget {
|
||||
const TrailingLoading({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
this.dimension = kMinInteractiveDimension,
|
||||
});
|
||||
|
||||
static const _spinnerSize = 24.0;
|
||||
|
||||
final bool isLoading;
|
||||
final double dimension;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSize(
|
||||
duration: context.motionDuration(commonDuration),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: isLoading
|
||||
? SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all((dimension - _spinnerSize) / 2),
|
||||
child: const CommonCircleLoading(),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommonCircleLoading extends StatefulWidget {
|
||||
static const double defaultDimension = 48;
|
||||
|
||||
@@ -78,6 +111,7 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
|
||||
var _currentMorphIndex = 0;
|
||||
var _morphRotationTargetAngle = _quarterRotation;
|
||||
Timer? _morphPause;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -96,6 +130,7 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_morphPause?.cancel();
|
||||
_morphController.dispose();
|
||||
_globalRotationController.dispose();
|
||||
super.dispose();
|
||||
@@ -252,7 +287,9 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
|
||||
final elapsed = DateTime.now().difference(startedAt);
|
||||
if (elapsed < _morphInterval) {
|
||||
await Future<void>.delayed(_morphInterval - elapsed);
|
||||
final paused = Completer<void>();
|
||||
_morphPause = Timer(_morphInterval - elapsed, paused.complete);
|
||||
await paused.future;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
|
||||
@@ -509,8 +509,10 @@ class _ElasticPressState extends State<ElasticPress>
|
||||
_lift.springTo(1, _liftSpring);
|
||||
}
|
||||
|
||||
// A pointer's events keep reaching the hit-test path recorded at its down,
|
||||
// so a press removed mid-gesture still hears its moves and release.
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (event.pointer != _pointer) {
|
||||
if (!mounted || event.pointer != _pointer) {
|
||||
return;
|
||||
}
|
||||
final limit = context.size!.shortestSide * _pullLimit;
|
||||
@@ -520,7 +522,7 @@ class _ElasticPressState extends State<ElasticPress>
|
||||
}
|
||||
|
||||
void _handlePointerEnd(PointerEvent event) {
|
||||
if (event.pointer != _pointer) {
|
||||
if (!mounted || event.pointer != _pointer) {
|
||||
return;
|
||||
}
|
||||
_pointer = null;
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
/*
|
||||
Adapted from navigator_resizable 3.1.0,
|
||||
https://github.com/fujidaiti/navigator_resizable
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024 Daichi Fujita
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const Curve _sizeCurve = Curves.easeInOutCubic;
|
||||
|
||||
/// Sizes itself to the content of the current route of the [Navigator] below
|
||||
/// it, and follows the routes' transitions from one size to the next.
|
||||
class NavigatorResizable extends StatefulWidget {
|
||||
const NavigatorResizable({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<NavigatorResizable> createState() => _NavigatorResizableState();
|
||||
}
|
||||
|
||||
class _NavigatorResizableState extends State<NavigatorResizable> {
|
||||
final _sizes = _RouteSizes();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sizes.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _RouteSizesScope(
|
||||
sizes: _sizes,
|
||||
child: _ResizableBox(preferredSize: _sizes, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteSizesScope extends InheritedWidget {
|
||||
const _RouteSizesScope({required this.sizes, required super.child});
|
||||
|
||||
final _RouteSizes sizes;
|
||||
|
||||
static _RouteSizes? of(BuildContext context) =>
|
||||
context.getInheritedWidgetOfExactType<_RouteSizesScope>()?.sizes;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_RouteSizesScope oldWidget) =>
|
||||
sizes != oldWidget.sizes;
|
||||
}
|
||||
|
||||
/// A route the [NavigatorResizable] above its navigator sizes to.
|
||||
mixin ResizableNavigatorRouteMixin<T> on DragBackRouteMixin<T> {
|
||||
_RouteSizes? _sizes;
|
||||
|
||||
/// Wraps the page the navigator sizes to while this route is current.
|
||||
Widget resizableContent(Widget child) {
|
||||
return _ContentBoundary(route: this, child: child);
|
||||
}
|
||||
|
||||
@override
|
||||
void install() {
|
||||
super.install();
|
||||
_sizes = _RouteSizesScope.of(navigator!.context)?.._didInstall(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sizes?._didDispose(this);
|
||||
_sizes = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didAdd() {
|
||||
super.didAdd();
|
||||
_sizes?._didAdd(this);
|
||||
}
|
||||
|
||||
@override
|
||||
TickerFuture didPush() {
|
||||
final result = super.didPush();
|
||||
_sizes?._didPush(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace(Route<dynamic>? oldRoute) {
|
||||
super.didReplace(oldRoute);
|
||||
_sizes?._didReplace(this, oldRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangePrevious(Route<dynamic>? previousRoute) {
|
||||
super.didChangePrevious(previousRoute);
|
||||
_sizes?._previousRouteOf[this] = previousRoute;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeNext(Route<dynamic>? nextRoute) {
|
||||
super.didChangeNext(nextRoute);
|
||||
_sizes?._didChangeNext(this, nextRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext(Route<dynamic> nextRoute) {
|
||||
super.didPopNext(nextRoute);
|
||||
_sizes?._uncover(this, nextRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStartDragBack() {
|
||||
super.didStartDragBack();
|
||||
_sizes?._didStartDragBack(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didEndDragBack() {
|
||||
super.didEndDragBack();
|
||||
_sizes?._didEndDragBack(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteSizes extends ChangeNotifier implements ValueListenable<Size> {
|
||||
final _contentSizes = <Route<dynamic>, Size>{};
|
||||
final _previousRouteOf = <Route<dynamic>, Route<dynamic>?>{};
|
||||
final _nextRouteOf = <Route<dynamic>, Route<dynamic>?>{};
|
||||
Route<dynamic>? _settledRoute;
|
||||
Animation<Size?>? _interpolation;
|
||||
({Route<dynamic> route, Animation<Size?> interpolation})? _drag;
|
||||
Size? _lastValidSize;
|
||||
|
||||
@override
|
||||
Size get value {
|
||||
final size = _interpolation == null
|
||||
? _contentSizes[_settledRoute]
|
||||
: _interpolation!.value;
|
||||
if (size != null && size.isFinite) {
|
||||
return _lastValidSize = size;
|
||||
}
|
||||
return _lastValidSize ?? Size.infinite;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_interpolation?.removeListener(notifyListeners);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _interpolate(Animation<Size?> interpolation) {
|
||||
_interpolation?.removeListener(notifyListeners);
|
||||
_interpolation = interpolation..addListener(notifyListeners);
|
||||
}
|
||||
|
||||
void _settle(Route<dynamic>? route) {
|
||||
final oldSize = value;
|
||||
_interpolation?.removeListener(notifyListeners);
|
||||
_interpolation = null;
|
||||
_settledRoute = route;
|
||||
if (value != oldSize) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _didChangeContentSize(Route<dynamic> route, Size size) {
|
||||
assert(_contentSizes.containsKey(route));
|
||||
final oldSize = value;
|
||||
_contentSizes[route] = size;
|
||||
if (value != oldSize) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _didInstall(Route<dynamic> route) {
|
||||
_contentSizes[route] = Size.infinite;
|
||||
}
|
||||
|
||||
void _didDispose(Route<dynamic> route) {
|
||||
_contentSizes.remove(route);
|
||||
_previousRouteOf.remove(route);
|
||||
_nextRouteOf.remove(route);
|
||||
if (_drag?.route == route) {
|
||||
_drag = null;
|
||||
}
|
||||
if (_settledRoute == route) {
|
||||
_settledRoute = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _didAdd(Route<dynamic> route) {
|
||||
if (route.isCurrent) {
|
||||
_settle(route);
|
||||
}
|
||||
}
|
||||
|
||||
void _didPush(ModalRoute<dynamic> route) {
|
||||
final animation = route.animation!;
|
||||
if (animation.isCompleted) {
|
||||
_settle(route);
|
||||
return;
|
||||
}
|
||||
final from = value;
|
||||
_interpolate(
|
||||
_LazySizeTween(start: () => from, end: () => _contentSizes[route])
|
||||
.chain(CurveTween(curve: _sizeCurve))
|
||||
.animate(_TransitionProgress(route)),
|
||||
);
|
||||
|
||||
void handleStatus(AnimationStatus status) {
|
||||
switch (status) {
|
||||
case AnimationStatus.completed when !route.offstage:
|
||||
animation.removeStatusListener(handleStatus);
|
||||
if (route.isCurrent) {
|
||||
_settle(route);
|
||||
}
|
||||
case AnimationStatus.dismissed:
|
||||
animation.removeStatusListener(handleStatus);
|
||||
case _:
|
||||
}
|
||||
}
|
||||
|
||||
animation.addStatusListener(handleStatus);
|
||||
}
|
||||
|
||||
void _didReplace(Route<dynamic> route, Route<dynamic>? oldRoute) {
|
||||
if (oldRoute == _settledRoute) {
|
||||
_settle(route);
|
||||
}
|
||||
}
|
||||
|
||||
void _didChangeNext(Route<dynamic> route, Route<dynamic>? nextRoute) {
|
||||
final lostNext =
|
||||
nextRoute == null && _nextRouteOf[route] != null && route.isCurrent;
|
||||
// Of the routes that were above, the top one still animating out drives
|
||||
// the way back; one removed without a transition leaves nothing to follow.
|
||||
Route<dynamic>? exiting;
|
||||
if (lostNext) {
|
||||
for (
|
||||
var above = _nextRouteOf[route];
|
||||
above != null;
|
||||
above = _nextRouteOf[above]
|
||||
) {
|
||||
if (above is TransitionRoute<dynamic> &&
|
||||
above.animation!.status == AnimationStatus.reverse) {
|
||||
exiting = above;
|
||||
}
|
||||
}
|
||||
}
|
||||
_nextRouteOf[route] = nextRoute;
|
||||
if (lostNext) {
|
||||
_uncover(route, exiting);
|
||||
}
|
||||
}
|
||||
|
||||
void _uncover(Route<dynamic> route, Route<dynamic>? exiting) {
|
||||
if (exiting != null && exiting == _drag?.route) {
|
||||
return;
|
||||
}
|
||||
if (!route.isCurrent) {
|
||||
return;
|
||||
}
|
||||
if (exiting is! TransitionRoute<dynamic> ||
|
||||
exiting.animation!.isDismissed) {
|
||||
_settle(route);
|
||||
return;
|
||||
}
|
||||
final animation = exiting.animation!;
|
||||
final progress = animation.value;
|
||||
final from = value;
|
||||
final Animatable<Size?> tween = progress == 1
|
||||
? _LazySizeTween(
|
||||
start: () => _contentSizes[route],
|
||||
end: () => from,
|
||||
).chain(CurveTween(curve: _sizeCurve))
|
||||
// Popped mid-transition: run linearly from where the size is now.
|
||||
: _LazySizeTween(
|
||||
start: () => _contentSizes[route],
|
||||
end: () => _lerpEndSize(_contentSizes[route]!, from, progress),
|
||||
);
|
||||
_interpolate(tween.animate(_TransitionProgress(exiting)));
|
||||
|
||||
void handleStatus(AnimationStatus status) {
|
||||
if (!status.isDismissed) {
|
||||
return;
|
||||
}
|
||||
animation.removeStatusListener(handleStatus);
|
||||
if (route.isCurrent) {
|
||||
_settle(route);
|
||||
}
|
||||
}
|
||||
|
||||
animation.addStatusListener(handleStatus);
|
||||
}
|
||||
|
||||
void _didStartDragBack(ModalRoute<dynamic> route) {
|
||||
final target = _previousRouteOf[route];
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
final from = value;
|
||||
_interpolate(
|
||||
_LazySizeTween(
|
||||
start: () => _contentSizes[target],
|
||||
end: () => from,
|
||||
).animate(route.animation!),
|
||||
);
|
||||
_drag = (route: route, interpolation: _interpolation!);
|
||||
}
|
||||
|
||||
void _didEndDragBack(Route<dynamic> route) {
|
||||
final drag = _drag;
|
||||
if (drag?.route != route) {
|
||||
return;
|
||||
}
|
||||
_drag = null;
|
||||
// A push or pop since the drag runs its own transition and settles it.
|
||||
if (_interpolation == drag!.interpolation) {
|
||||
_settle(_contentSizes.keys.where((it) => it.isCurrent).firstOrNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TransitionProgress extends Animation<double>
|
||||
with AnimationWithParentMixin<double> {
|
||||
_TransitionProgress(this.route);
|
||||
|
||||
final TransitionRoute<dynamic> route;
|
||||
|
||||
@override
|
||||
Animation<double> get parent => route.animation!;
|
||||
|
||||
// A pushed route's first frame is built offstage with its animation at 1.
|
||||
@override
|
||||
double get value => switch (route) {
|
||||
ModalRoute<dynamic>(offstage: true) => 0.0,
|
||||
_ => parent.value,
|
||||
};
|
||||
}
|
||||
|
||||
class _LazySizeTween extends Animatable<Size?> {
|
||||
_LazySizeTween({required this.start, required this.end});
|
||||
|
||||
final ValueGetter<Size?> start;
|
||||
final ValueGetter<Size?> end;
|
||||
|
||||
@override
|
||||
Size? transform(double t) {
|
||||
final start = this.start();
|
||||
if (start?.isFinite != true) {
|
||||
return null;
|
||||
}
|
||||
final end = this.end();
|
||||
if (end?.isFinite != true) {
|
||||
return null;
|
||||
}
|
||||
return Size.lerp(start, end, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The size a linear run toward [start] would have begun at, given it is at
|
||||
/// [current] when [t] of it remains.
|
||||
Size _lerpEndSize(Size start, Size current, double t) {
|
||||
assert(0 < t && t <= 1);
|
||||
return Size(
|
||||
(current.width - (1 - t) * start.width) / t,
|
||||
(current.height - (1 - t) * start.height) / t,
|
||||
);
|
||||
}
|
||||
|
||||
class _ResizableBox extends SingleChildRenderObjectWidget {
|
||||
const _ResizableBox({required this.preferredSize, required super.child});
|
||||
|
||||
final ValueListenable<Size> preferredSize;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderResizableBox(preferredSize);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderResizableBox renderObject,
|
||||
) {
|
||||
renderObject.preferredSize = preferredSize;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderResizableBox extends RenderAligningShiftedBox {
|
||||
_RenderResizableBox(this._preferredSize)
|
||||
: super(alignment: Alignment.topLeft, textDirection: null) {
|
||||
_preferredSize.addListener(_handlePreferredSizeChange);
|
||||
}
|
||||
|
||||
ValueListenable<Size> _preferredSize;
|
||||
bool _disposed = false;
|
||||
|
||||
// ignore: avoid_setters_without_getters
|
||||
set preferredSize(ValueListenable<Size> value) {
|
||||
if (value == _preferredSize) {
|
||||
return;
|
||||
}
|
||||
_preferredSize.removeListener(_handlePreferredSizeChange);
|
||||
_preferredSize = value..addListener(_handlePreferredSizeChange);
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
// A page reports its size from its own layout, which runs after this box's
|
||||
// in the same frame, so the new size waits for the next one.
|
||||
void _handlePreferredSizeChange() {
|
||||
if (SchedulerBinding.instance.schedulerPhase ==
|
||||
SchedulerPhase.persistentCallbacks) {
|
||||
SchedulerBinding.instance.scheduleFrameCallback((_) {
|
||||
if (!_disposed) {
|
||||
markNeedsLayout();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_preferredSize.removeListener(_handlePreferredSizeChange);
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
return constraints.constrain(_preferredSize.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
assert(
|
||||
!constraints.isTight,
|
||||
'NavigatorResizable sizes itself to its route, so it needs loose '
|
||||
'constraints, but got $constraints from ${parent.runtimeType}.',
|
||||
);
|
||||
assert(
|
||||
constraints.hasBoundedWidth && constraints.hasBoundedHeight,
|
||||
'NavigatorResizable lays its routes out within its own constraints, so '
|
||||
'they must be bounded, but got $constraints from ${parent.runtimeType}.',
|
||||
);
|
||||
// The navigator gets all the room and may overflow this box.
|
||||
child!.layout(constraints, parentUsesSize: true);
|
||||
size = computeDryLayout(constraints);
|
||||
alignChild();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
layer = context.pushClipRect(
|
||||
needsCompositing,
|
||||
offset,
|
||||
Offset.zero & size,
|
||||
super.paint,
|
||||
oldLayer: layer as ClipRectLayer?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContentBoundary extends SingleChildRenderObjectWidget {
|
||||
const _ContentBoundary({required this.route, required super.child});
|
||||
|
||||
final ResizableNavigatorRouteMixin<dynamic> route;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderContentBoundary(route);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderContentBoundary renderObject,
|
||||
) {
|
||||
renderObject.route = route;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderContentBoundary extends RenderPositionedBox {
|
||||
_RenderContentBoundary(this.route) : super(alignment: Alignment.topLeft);
|
||||
|
||||
ResizableNavigatorRouteMixin<dynamic> route;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
if (child?.size case final size?) {
|
||||
// A debug build's size holds on to the render box it came from.
|
||||
route._sizes?._didChangeContentSize(route, Size.copy(size));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
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:fl_clash/widgets/fade_box.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -18,9 +20,12 @@ enum NullStatusIllustration {
|
||||
requests,
|
||||
dns,
|
||||
ntp,
|
||||
sniffer,
|
||||
tun,
|
||||
wifi,
|
||||
apps,
|
||||
history,
|
||||
icons,
|
||||
fonts,
|
||||
permission,
|
||||
camera,
|
||||
error,
|
||||
@@ -32,6 +37,10 @@ class NullStatusSwitcher extends StatefulWidget {
|
||||
final bool isLoading;
|
||||
final bool isEmpty;
|
||||
final bool isSearching;
|
||||
|
||||
/// Keeps the status it opens on for at least about a route transition, so
|
||||
/// content that arrives just after does not flash it.
|
||||
final bool holdsArrival;
|
||||
final NullStatus nullStatus;
|
||||
final Widget child;
|
||||
|
||||
@@ -40,6 +49,7 @@ class NullStatusSwitcher extends StatefulWidget {
|
||||
this.isLoading = false,
|
||||
required this.isEmpty,
|
||||
this.isSearching = false,
|
||||
this.holdsArrival = false,
|
||||
required this.nullStatus,
|
||||
required this.child,
|
||||
});
|
||||
@@ -50,8 +60,23 @@ class NullStatusSwitcher extends StatefulWidget {
|
||||
|
||||
class _NullStatusSwitcherState extends State<NullStatusSwitcher> {
|
||||
static const _exitDuration = Duration(milliseconds: 150);
|
||||
static const _arrivalHoldDuration = Duration(milliseconds: 400);
|
||||
|
||||
late bool _loaded = !widget.isLoading;
|
||||
Timer? _arrivalHold;
|
||||
NullStatus? _heldStatus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.holdsArrival && _loaded && widget.isEmpty) {
|
||||
_heldStatus = widget.nullStatus;
|
||||
_arrivalHold = Timer(
|
||||
_arrivalHoldDuration,
|
||||
() => setState(() => _heldStatus = null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NullStatusSwitcher oldWidget) {
|
||||
@@ -59,11 +84,23 @@ class _NullStatusSwitcherState extends State<NullStatusSwitcher> {
|
||||
_loaded = _loaded || !widget.isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_arrivalHold?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
final heldStatus = _heldStatus;
|
||||
final slot = switch ((widget.isEmpty, widget.isSearching)) {
|
||||
(true, true) => _NullStatusSlot.noResults,
|
||||
(false, _) when heldStatus == null => _NullStatusSlot.content,
|
||||
_ => _NullStatusSlot.empty,
|
||||
};
|
||||
return AnimatedSwitcher(
|
||||
duration: context.motionDuration(commonDuration),
|
||||
reverseDuration: context.motionDuration(_exitDuration),
|
||||
@@ -84,54 +121,66 @@ class _NullStatusSwitcherState extends State<NullStatusSwitcher> {
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: switch ((widget.isEmpty, widget.isSearching)) {
|
||||
(true, true) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.noResults),
|
||||
child: NullStatus(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(slot),
|
||||
child: switch (slot) {
|
||||
_NullStatusSlot.noResults => NullStatus(
|
||||
label: context.appLocalizations.noSearchResults,
|
||||
illustration: NullStatusIllustration.search,
|
||||
),
|
||||
),
|
||||
(true, false) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.empty),
|
||||
child: widget.nullStatus,
|
||||
),
|
||||
(false, _) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.content),
|
||||
child: widget.child,
|
||||
),
|
||||
},
|
||||
_NullStatusSlot.empty => heldStatus ?? widget.nullStatus,
|
||||
_NullStatusSlot.content => widget.child,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NullStatusSlot { empty, noResults, content }
|
||||
|
||||
class NullStatus extends StatelessWidget {
|
||||
final String label;
|
||||
class NullStatus extends StatefulWidget {
|
||||
final String? label;
|
||||
final String? description;
|
||||
final Widget? action;
|
||||
final NullStatusIllustration illustration;
|
||||
|
||||
const NullStatus({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.label,
|
||||
this.description,
|
||||
this.action,
|
||||
this.illustration = NullStatusIllustration.data,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NullStatus> createState() => _NullStatusState();
|
||||
}
|
||||
|
||||
class _NullStatusState extends State<NullStatus> {
|
||||
late final ScrollController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = sheetScrollController(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final description = this.description;
|
||||
final action = this.action;
|
||||
final NullStatus(:label, :description, :action, :illustration) = widget;
|
||||
final textTheme = context.textTheme;
|
||||
final colorScheme = context.colorScheme;
|
||||
final compact = MediaQuery.sizeOf(context).height < _compactHeight;
|
||||
return Align(
|
||||
alignment: const Alignment(0.0, -0.2),
|
||||
child: SingleChildScrollView(
|
||||
primary: false,
|
||||
controller: _controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
@@ -144,17 +193,19 @@ class NullStatus extends StatelessWidget {
|
||||
dimension: compact ? 120 : 160,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 12 : 16),
|
||||
_EnterItem(
|
||||
delay: _staggerStep,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
if (label != null) ...[
|
||||
SizedBox(height: compact ? 12 : 16),
|
||||
_EnterItem(
|
||||
delay: _staggerStep,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_EnterItem(
|
||||
@@ -172,7 +223,12 @@ class NullStatus extends StatelessWidget {
|
||||
const SizedBox(height: 24),
|
||||
_EnterItem(
|
||||
delay: _staggerStep * (description != null ? 3 : 2),
|
||||
child: action,
|
||||
child: Theme(
|
||||
data: Theme.of(
|
||||
context,
|
||||
).copyWith(visualDensity: VisualDensity.standard),
|
||||
child: action,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -187,13 +243,8 @@ const _compactHeight = 560.0;
|
||||
const _staggerStep = Duration(milliseconds: 60);
|
||||
const _enterRise = 12.0;
|
||||
|
||||
bool _skipsEntrance(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
final routeEntering =
|
||||
route != null &&
|
||||
(route.offstage || (route.animation?.isAnimating ?? false));
|
||||
return context.disableAnimations || routeEntering;
|
||||
}
|
||||
bool _skipsEntrance(BuildContext context) =>
|
||||
context.disableAnimations || isRouteArriving(context);
|
||||
|
||||
class _EnterItem extends StatefulWidget {
|
||||
final Duration delay;
|
||||
@@ -357,6 +408,8 @@ class _EmptyIllustrationState extends State<EmptyIllustration>
|
||||
),
|
||||
NullStatusIllustration.dns => (MaterialShapes.puffy, AppGlyphs.dns),
|
||||
NullStatusIllustration.ntp => (MaterialShapes.pentagon, AppGlyphs.clock),
|
||||
NullStatusIllustration.sniffer => (MaterialShapes.gem, AppGlyphs.eye),
|
||||
NullStatusIllustration.tun => (MaterialShapes.clamShell, AppGlyphs.vpn),
|
||||
NullStatusIllustration.wifi => (
|
||||
MaterialShapes.cookie7Sided,
|
||||
AppGlyphs.wifi,
|
||||
@@ -365,9 +418,13 @@ class _EmptyIllustrationState extends State<EmptyIllustration>
|
||||
MaterialShapes.clover4Leaf,
|
||||
AppGlyphs.appsList,
|
||||
),
|
||||
NullStatusIllustration.history => (
|
||||
MaterialShapes.cookie12Sided,
|
||||
AppGlyphs.history,
|
||||
NullStatusIllustration.icons => (
|
||||
MaterialShapes.verySunny,
|
||||
AppGlyphs.iconTile,
|
||||
),
|
||||
NullStatusIllustration.fonts => (
|
||||
MaterialShapes.puffyDiamond,
|
||||
AppGlyphs.font,
|
||||
),
|
||||
NullStatusIllustration.permission => (MaterialShapes.sunny, AppGlyphs.lock),
|
||||
NullStatusIllustration.camera => (
|
||||
|
||||
+428
-360
@@ -1,5 +1,8 @@
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:fl_clash/widgets/deferred_push.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/keyboard_inset_hold.dart';
|
||||
|
||||
@@ -12,8 +15,6 @@ typedef OpenContainerBuilder<S> =
|
||||
typedef CloseContainerBuilder =
|
||||
Widget Function(BuildContext context, VoidCallback action);
|
||||
|
||||
enum ContainerTransitionType { fade, fadeThrough }
|
||||
|
||||
typedef ClosedCallback<S> = void Function(S data);
|
||||
|
||||
@optionalTypeArgs
|
||||
@@ -22,7 +23,6 @@ class OpenContainer<T extends Object?> extends StatefulWidget {
|
||||
super.key,
|
||||
this.closedColor,
|
||||
this.openColor,
|
||||
this.middleColor,
|
||||
this.closedShape,
|
||||
this.openShape,
|
||||
this.onClosed,
|
||||
@@ -30,16 +30,12 @@ class OpenContainer<T extends Object?> extends StatefulWidget {
|
||||
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;
|
||||
@@ -47,10 +43,7 @@ class OpenContainer<T extends Object?> extends StatefulWidget {
|
||||
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;
|
||||
|
||||
@override
|
||||
@@ -62,30 +55,20 @@ class _OpenContainerState<T> extends State<OpenContainer<T?>> {
|
||||
final GlobalKey _closedBuilderKey = GlobalKey();
|
||||
|
||||
Future<void> openContainer() async {
|
||||
final Color middleColor =
|
||||
widget.middleColor ?? Theme.of(context).canvasColor;
|
||||
final T? data =
|
||||
await Navigator.of(
|
||||
context,
|
||||
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,
|
||||
),
|
||||
);
|
||||
final T? data = await Navigator.of(context).push(
|
||||
_OpenContainerRoute<T>(
|
||||
closedColor: widget.closedColor,
|
||||
openColor: widget.openColor,
|
||||
closedShape: widget.closedShape,
|
||||
openShape: widget.openShape,
|
||||
closedBuilder: widget.closedBuilder,
|
||||
openBuilder: widget.openBuilder,
|
||||
hideableKey: _hideableKey,
|
||||
closedBuilderKey: _closedBuilderKey,
|
||||
transitionDuration: widget.transitionDuration,
|
||||
curve: widget.curve,
|
||||
),
|
||||
);
|
||||
if (widget.onClosed != null) {
|
||||
widget.onClosed!(data);
|
||||
}
|
||||
@@ -164,125 +147,23 @@ class _HideableState extends State<_Hideable> {
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
class _OpenContainerRoute<T> extends ModalRoute<T>
|
||||
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
|
||||
_OpenContainerRoute({
|
||||
required this.closedColor,
|
||||
required this.openColor,
|
||||
required this.middleColor,
|
||||
required ShapeBorder? closedShape,
|
||||
required this.openShape,
|
||||
required ShapeBorder? 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({
|
||||
required ContainerTransitionType transitionType,
|
||||
required Color closedColor,
|
||||
required Color openColor,
|
||||
required Color middleColor,
|
||||
}) {
|
||||
switch (transitionType) {
|
||||
case ContainerTransitionType.fade:
|
||||
return _FlippableTweenSequence<Color?>(<TweenSequenceItem<Color?>>[
|
||||
TweenSequenceItem<Color>(
|
||||
tween: ConstantTween<Color>(closedColor),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<Color?>(
|
||||
tween: ColorTween(begin: closedColor, end: openColor),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<Color>(
|
||||
tween: ConstantTween<Color>(openColor),
|
||||
weight: 3 / 5,
|
||||
),
|
||||
]);
|
||||
case ContainerTransitionType.fadeThrough:
|
||||
return _FlippableTweenSequence<Color?>(<TweenSequenceItem<Color?>>[
|
||||
TweenSequenceItem<Color?>(
|
||||
tween: ColorTween(begin: closedColor, end: middleColor),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<Color?>(
|
||||
tween: ColorTween(begin: middleColor, end: openColor),
|
||||
weight: 4 / 5,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
static _FlippableTweenSequence<double> _getClosedOpacityTween(
|
||||
ContainerTransitionType transitionType,
|
||||
) {
|
||||
switch (transitionType) {
|
||||
case ContainerTransitionType.fade:
|
||||
return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(1.0),
|
||||
weight: 1,
|
||||
),
|
||||
]);
|
||||
case ContainerTransitionType.fadeThrough:
|
||||
return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(begin: 1.0, end: 0.0),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(0.0),
|
||||
weight: 4 / 5,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
static _FlippableTweenSequence<double> _getOpenOpacityTween(
|
||||
ContainerTransitionType transitionType,
|
||||
) {
|
||||
switch (transitionType) {
|
||||
case ContainerTransitionType.fade:
|
||||
return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(0.0),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(1.0),
|
||||
weight: 3 / 5,
|
||||
),
|
||||
]);
|
||||
case ContainerTransitionType.fadeThrough:
|
||||
return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(0.0),
|
||||
weight: 1 / 5,
|
||||
),
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
weight: 4 / 5,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}) : _shapeTween = ShapeBorderTween(begin: closedShape, end: openShape);
|
||||
|
||||
final Color? closedColor;
|
||||
final Color? openColor;
|
||||
final Color middleColor;
|
||||
final ShapeBorder? openShape;
|
||||
final CloseContainerBuilder closedBuilder;
|
||||
final OpenContainerBuilder<T> openBuilder;
|
||||
final GlobalKey<_HideableState> hideableKey;
|
||||
@@ -290,25 +171,50 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
|
||||
@override
|
||||
final Duration transitionDuration;
|
||||
final ContainerTransitionType transitionType;
|
||||
final Curve curve;
|
||||
late final Curve _reverseCurve = curve.flipped;
|
||||
|
||||
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,
|
||||
);
|
||||
late _FlippableTweenSequence<Color?> _colorTween;
|
||||
final GlobalKey _openBuilderKey = GlobalKey();
|
||||
final RectTween _rectTween = RectTween();
|
||||
|
||||
// The closed child stays in this route from the push until the route is
|
||||
// dismissed or dragged back, so it moves between its tile and the route
|
||||
// twice per cycle rather than at every status change.
|
||||
final _closedRect = ValueNotifier<Rect?>(null);
|
||||
final _hostsClosed = ValueNotifier<bool>(false);
|
||||
late final Listenable _closedChanges = Listenable.merge([
|
||||
_closedRect,
|
||||
_hostsClosed,
|
||||
]);
|
||||
late final _openOpacity = _OpenOpacity(this);
|
||||
|
||||
AnimationStatus? _lastAnimationStatus;
|
||||
AnimationStatus? _currentAnimationStatus;
|
||||
|
||||
bool get _settled => animation!.isCompleted || isDragBackActive;
|
||||
|
||||
bool get _closing =>
|
||||
animation!.status == AnimationStatus.reverse &&
|
||||
!_transitionWasInterrupted;
|
||||
|
||||
double get _progress {
|
||||
final t = animation!.value;
|
||||
if (t == 0 || t == 1) {
|
||||
return t;
|
||||
}
|
||||
return (_closing ? _reverseCurve : curve).transform(t);
|
||||
}
|
||||
|
||||
/// How far the open page and the container color have faded in. Both
|
||||
/// change over a fifth of the transition: the second fifth on the way in,
|
||||
/// the fourth on the way out.
|
||||
double get _fade {
|
||||
if (_settled) {
|
||||
return 1;
|
||||
}
|
||||
final start = _closing ? 0.6 : 0.2;
|
||||
return clampDouble((animation!.value - start) / 0.2, 0, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
TickerFuture didPush() {
|
||||
_takeMeasurements(navigatorContext: hideableKey.currentContext!);
|
||||
@@ -318,10 +224,12 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
_currentAnimationStatus = status;
|
||||
switch (status) {
|
||||
case AnimationStatus.dismissed:
|
||||
_toggleHideable(hide: false);
|
||||
_returnClosedChild(visible: true);
|
||||
break;
|
||||
case AnimationStatus.completed:
|
||||
_toggleHideable(hide: true);
|
||||
if (!_hostsClosed.value) {
|
||||
hideableKey.currentState?.isVisible = false;
|
||||
}
|
||||
break;
|
||||
case AnimationStatus.forward:
|
||||
case AnimationStatus.reverse:
|
||||
@@ -346,20 +254,28 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (hideableKey.currentState?.isVisible == false) {
|
||||
final hideable = hideableKey.currentState;
|
||||
if (hideable != null && (!hideable.isInTree || !hideable.isVisible)) {
|
||||
SchedulerBinding.instance.addPostFrameCallback(
|
||||
(Duration d) => _toggleHideable(hide: false),
|
||||
(Duration d) => hideableKey.currentState
|
||||
?..placeholderSize = null
|
||||
..isVisible = true,
|
||||
);
|
||||
}
|
||||
super.dispose();
|
||||
_closedRect.dispose();
|
||||
_hostsClosed.dispose();
|
||||
}
|
||||
|
||||
void _toggleHideable({required bool hide}) {
|
||||
if (hideableKey.currentState != null) {
|
||||
hideableKey.currentState!
|
||||
..placeholderSize = null
|
||||
..isVisible = !hide;
|
||||
void _returnClosedChild({required bool visible}) {
|
||||
final hideable = hideableKey.currentState;
|
||||
if (hideable == null) {
|
||||
return;
|
||||
}
|
||||
hideable
|
||||
..placeholderSize = null
|
||||
..isVisible = visible;
|
||||
_hostsClosed.value = false;
|
||||
}
|
||||
|
||||
void _takeMeasurements({
|
||||
@@ -367,20 +283,16 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
bool delayForSourceRoute = false,
|
||||
}) {
|
||||
final RenderBox navigator =
|
||||
Navigator.of(
|
||||
navigatorContext,
|
||||
rootNavigator: useRootNavigator,
|
||||
).context.findRenderObject()!
|
||||
as RenderBox;
|
||||
final Size navSize = _getSize(navigator);
|
||||
_rectTween.end = Offset.zero & navSize;
|
||||
Navigator.of(navigatorContext).context.findRenderObject()! as RenderBox;
|
||||
|
||||
void takeMeasurementsInSourceRoute([Duration? _]) {
|
||||
if (!navigator.attached || hideableKey.currentContext == null) {
|
||||
return;
|
||||
}
|
||||
_rectTween.begin = _getRect(hideableKey, navigator);
|
||||
hideableKey.currentState!.placeholderSize = _rectTween.begin!.size;
|
||||
final rect = _getRect(hideableKey, navigator);
|
||||
_closedRect.value = rect;
|
||||
hideableKey.currentState!.placeholderSize = rect.size;
|
||||
_hostsClosed.value = true;
|
||||
}
|
||||
|
||||
if (delayForSourceRoute) {
|
||||
@@ -392,11 +304,6 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
}
|
||||
}
|
||||
|
||||
Size _getSize(RenderBox render) {
|
||||
assert(render.hasSize);
|
||||
return render.size;
|
||||
}
|
||||
|
||||
Rect _getRect(GlobalKey key, RenderBox ancestor) {
|
||||
assert(key.currentContext != null);
|
||||
assert(ancestor.hasSize);
|
||||
@@ -409,36 +316,9 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
);
|
||||
}
|
||||
|
||||
bool get _transitionWasInterrupted {
|
||||
bool wasInProgress = false;
|
||||
bool isInProgress = false;
|
||||
|
||||
switch (_currentAnimationStatus) {
|
||||
case AnimationStatus.completed:
|
||||
case AnimationStatus.dismissed:
|
||||
isInProgress = false;
|
||||
break;
|
||||
case AnimationStatus.forward:
|
||||
case AnimationStatus.reverse:
|
||||
isInProgress = true;
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
switch (_lastAnimationStatus) {
|
||||
case AnimationStatus.completed:
|
||||
case AnimationStatus.dismissed:
|
||||
wasInProgress = false;
|
||||
break;
|
||||
case AnimationStatus.forward:
|
||||
case AnimationStatus.reverse:
|
||||
wasInProgress = true;
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
return wasInProgress && isInProgress;
|
||||
}
|
||||
bool get _transitionWasInterrupted =>
|
||||
(_lastAnimationStatus?.isAnimating ?? false) &&
|
||||
(_currentAnimationStatus?.isAnimating ?? false);
|
||||
|
||||
void closeContainer({T? returnValue}) {
|
||||
Navigator.of(subtreeContext!).pop(returnValue);
|
||||
@@ -450,148 +330,59 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
_colorTween = _getColorTween(
|
||||
transitionType: transitionType,
|
||||
closedColor: closedColor ?? surface,
|
||||
openColor: openColor ?? surface,
|
||||
middleColor: middleColor,
|
||||
final theme = Theme.of(context);
|
||||
final surface = theme.colorScheme.surface;
|
||||
final open = FadeTransition(
|
||||
opacity: _openOpacity,
|
||||
child: RepaintBoundary(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return KeyboardInsetHold(
|
||||
child: openBuilder(context, closeContainer),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
if (animation.isCompleted || isDragBackActive) {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
shape: openShape,
|
||||
child: Builder(
|
||||
key: _openBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
return KeyboardInsetHold(
|
||||
child: openBuilder(context, closeContainer),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Animation<double> curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: curve,
|
||||
reverseCurve: _transitionWasInterrupted ? null : curve.flipped,
|
||||
);
|
||||
TweenSequence<Color?>? colorTween;
|
||||
TweenSequence<double>? closedOpacityTween, openOpacityTween;
|
||||
switch (animation.status) {
|
||||
case AnimationStatus.dismissed:
|
||||
case AnimationStatus.forward:
|
||||
closedOpacityTween = _closedOpacityTween;
|
||||
openOpacityTween = _openOpacityTween;
|
||||
colorTween = _colorTween;
|
||||
break;
|
||||
case AnimationStatus.reverse:
|
||||
if (_transitionWasInterrupted) {
|
||||
closedOpacityTween = _closedOpacityTween;
|
||||
openOpacityTween = _openOpacityTween;
|
||||
colorTween = _colorTween;
|
||||
break;
|
||||
}
|
||||
closedOpacityTween = _closedOpacityTween.flipped;
|
||||
openOpacityTween = _openOpacityTween.flipped;
|
||||
colorTween = _colorTween.flipped;
|
||||
break;
|
||||
case AnimationStatus.completed:
|
||||
assert(false); // Unreachable.
|
||||
break;
|
||||
}
|
||||
assert(colorTween != null);
|
||||
assert(closedOpacityTween != null);
|
||||
assert(openOpacityTween != null);
|
||||
|
||||
final Rect rect = _rectTween.evaluate(curvedAnimation)!;
|
||||
return SizedBox.expand(
|
||||
child: Container(
|
||||
color: _scrimTween.evaluate(curvedAnimation),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Transform.translate(
|
||||
offset: Offset(rect.left, rect.top),
|
||||
child: SizedBox(
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
child: Material(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
animationDuration: Duration.zero,
|
||||
color: colorTween!.evaluate(animation),
|
||||
shape: _shapeTween.evaluate(curvedAnimation),
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
// Closed child fading out.
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(
|
||||
width: _rectTween.begin!.width,
|
||||
height: _rectTween.begin!.height,
|
||||
child:
|
||||
(hideableKey.currentState?.isInTree ?? false)
|
||||
? null
|
||||
: FadeTransition(
|
||||
opacity: closedOpacityTween!.animate(
|
||||
animation,
|
||||
),
|
||||
child: Builder(
|
||||
key: closedBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
// Use dummy "open container" callback
|
||||
// since we are in the process of opening.
|
||||
return closedBuilder(context, () {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Open child fading in.
|
||||
OverflowBox(
|
||||
maxWidth: _rectTween.end!.width,
|
||||
maxHeight: _rectTween.end!.height,
|
||||
alignment: Alignment.topLeft,
|
||||
child: FadeTransition(
|
||||
opacity: openOpacityTween!.animate(animation),
|
||||
child: RepaintBoundary(
|
||||
child: Builder(
|
||||
key: _openBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
return KeyboardInsetHold(
|
||||
child: openBuilder(
|
||||
context,
|
||||
closeContainer,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return ListenableBuilder(
|
||||
listenable: _closedChanges,
|
||||
builder: (BuildContext context, Widget? open) {
|
||||
return _ContainerTransition(
|
||||
route: this,
|
||||
closedColor: closedColor ?? surface,
|
||||
openColor: openColor ?? surface,
|
||||
settledColor: theme.canvasColor,
|
||||
closed: _hostsClosed.value
|
||||
? IgnorePointer(
|
||||
child: ExcludeFocus(
|
||||
child: ExcludeSemantics(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Builder(
|
||||
key: closedBuilderKey,
|
||||
builder: (BuildContext context) {
|
||||
// Use dummy "open container" callback
|
||||
// since we are in the process of opening.
|
||||
return closedBuilder(context, () {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
open: open!,
|
||||
);
|
||||
},
|
||||
child: open,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStartDragBack() => _toggleHideable(hide: false);
|
||||
void didStartDragBack() => _returnClosedChild(visible: true);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
@@ -600,9 +391,7 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return dragBackDetector(
|
||||
isDragBackActive ? dragBackSlide(context, animation, child) : child,
|
||||
);
|
||||
return dragBackDetector(dragBackSlide(context, animation, child));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -621,25 +410,304 @@ class _OpenContainerRoute<T> extends ModalRoute<T> with DragBackRouteMixin<T> {
|
||||
String? get barrierLabel => null;
|
||||
}
|
||||
|
||||
class _FlippableTweenSequence<T> extends TweenSequence<T> {
|
||||
_FlippableTweenSequence(this._items) : super(_items);
|
||||
class _OpenOpacity extends Animation<double>
|
||||
with AnimationWithParentMixin<double> {
|
||||
_OpenOpacity(this.route);
|
||||
|
||||
final List<TweenSequenceItem<T>> _items;
|
||||
_FlippableTweenSequence<T>? _flipped;
|
||||
final _OpenContainerRoute<dynamic> route;
|
||||
|
||||
_FlippableTweenSequence<T>? get flipped {
|
||||
if (_flipped == null) {
|
||||
final List<TweenSequenceItem<T>> newItems = <TweenSequenceItem<T>>[];
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
newItems.add(
|
||||
TweenSequenceItem<T>(
|
||||
tween: _items[i].tween,
|
||||
weight: _items[_items.length - 1 - i].weight,
|
||||
),
|
||||
);
|
||||
}
|
||||
_flipped = _FlippableTweenSequence<T>(newItems);
|
||||
}
|
||||
return _flipped;
|
||||
@override
|
||||
Animation<double> get parent => route.animation!;
|
||||
|
||||
@override
|
||||
double get value => route._fade;
|
||||
}
|
||||
|
||||
enum _Slot { closed, open }
|
||||
|
||||
/// Grows the container from the closed child's rect to the whole route by
|
||||
/// painting alone: the open page is laid out once at its final size and
|
||||
/// moved under a clip, and the closed child is scaled to the rect's width.
|
||||
class _ContainerTransition
|
||||
extends SlottedMultiChildRenderObjectWidget<_Slot, RenderBox> {
|
||||
const _ContainerTransition({
|
||||
required this.route,
|
||||
required this.closedColor,
|
||||
required this.openColor,
|
||||
required this.settledColor,
|
||||
required this.closed,
|
||||
required this.open,
|
||||
});
|
||||
|
||||
final _OpenContainerRoute<dynamic> route;
|
||||
final Color closedColor;
|
||||
final Color openColor;
|
||||
final Color settledColor;
|
||||
final Widget? closed;
|
||||
final Widget open;
|
||||
|
||||
@override
|
||||
Iterable<_Slot> get slots => _Slot.values;
|
||||
|
||||
@override
|
||||
Widget? childForSlot(_Slot slot) => switch (slot) {
|
||||
_Slot.closed => closed,
|
||||
_Slot.open => open,
|
||||
};
|
||||
|
||||
@override
|
||||
_RenderContainerTransition createRenderObject(BuildContext context) {
|
||||
return _RenderContainerTransition(
|
||||
route: route,
|
||||
closedColor: closedColor,
|
||||
openColor: openColor,
|
||||
settledColor: settledColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderContainerTransition renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..route = route
|
||||
..closedColor = closedColor
|
||||
..openColor = openColor
|
||||
..settledColor = settledColor
|
||||
..markNeedsLayout();
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderContainerTransition extends RenderBox
|
||||
with SlottedContainerRenderObjectMixin<_Slot, RenderBox> {
|
||||
_RenderContainerTransition({
|
||||
required _OpenContainerRoute<dynamic> route,
|
||||
required Color closedColor,
|
||||
required Color openColor,
|
||||
required Color settledColor,
|
||||
}) : _route = route,
|
||||
_closedColor = closedColor,
|
||||
_openColor = openColor,
|
||||
_settledColor = settledColor;
|
||||
|
||||
_OpenContainerRoute<dynamic> get route => _route;
|
||||
_OpenContainerRoute<dynamic> _route;
|
||||
set route(_OpenContainerRoute<dynamic> value) {
|
||||
if (identical(value, _route)) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_unlisten();
|
||||
}
|
||||
_route = value;
|
||||
if (attached) {
|
||||
_listen();
|
||||
}
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Color _closedColor;
|
||||
set closedColor(Color value) {
|
||||
if (value == _closedColor) {
|
||||
return;
|
||||
}
|
||||
_closedColor = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Color _openColor;
|
||||
set openColor(Color value) {
|
||||
if (value == _openColor) {
|
||||
return;
|
||||
}
|
||||
_openColor = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Color _settledColor;
|
||||
set settledColor(Color value) {
|
||||
if (value == _settledColor) {
|
||||
return;
|
||||
}
|
||||
_settledColor = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
RenderBox? get _closed => childForSlot(_Slot.closed);
|
||||
RenderBox? get _open => childForSlot(_Slot.open);
|
||||
|
||||
final _clipLayer = LayerHandle<ContainerLayer>();
|
||||
final _closedTransformLayer = LayerHandle<TransformLayer>();
|
||||
|
||||
void _handleStatus(AnimationStatus _) => markNeedsPaint();
|
||||
|
||||
void _listen() {
|
||||
_route.animation!
|
||||
..addListener(markNeedsPaint)
|
||||
..addStatusListener(_handleStatus);
|
||||
}
|
||||
|
||||
void _unlisten() {
|
||||
_route.animation!
|
||||
..removeListener(markNeedsPaint)
|
||||
..removeStatusListener(_handleStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_listen();
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_unlisten();
|
||||
super.detach();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clipLayer.layer = null;
|
||||
_closedTransformLayer.layer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Rect? get _closedRect => _route._closedRect.value;
|
||||
|
||||
/// The container's rect, or null once the route has settled.
|
||||
Rect? get _rect {
|
||||
if (_route._settled) {
|
||||
return null;
|
||||
}
|
||||
final full = Offset.zero & size;
|
||||
return Rect.lerp(_closedRect ?? full, full, _route._progress)!;
|
||||
}
|
||||
|
||||
Matrix4? _closedTransform(Rect rect) {
|
||||
final closedRect = _closedRect;
|
||||
if (closedRect == null || closedRect.width == 0) {
|
||||
return null;
|
||||
}
|
||||
final scale = rect.width / closedRect.width;
|
||||
return Matrix4.translationValues(rect.left, rect.top, 0)
|
||||
..scaleByDouble(scale, scale, 1, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
return constraints.biggest;
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = constraints.biggest;
|
||||
_closed?.layout(BoxConstraints.tight(_closedRect?.size ?? Size.zero));
|
||||
_open?.layout(BoxConstraints.tight(size));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final rect = _rect;
|
||||
if (rect == null) {
|
||||
_clipLayer.layer = null;
|
||||
_closedTransformLayer.layer = null;
|
||||
context.canvas.drawRect(offset & size, Paint()..color = _settledColor);
|
||||
context.paintChild(_open!, offset);
|
||||
return;
|
||||
}
|
||||
final path = _route._shapeTween.lerp(_route._progress)?.getOuterPath(rect);
|
||||
final color = Paint()
|
||||
..color = Color.lerp(_closedColor, _openColor, _route._fade)!;
|
||||
if (path == null) {
|
||||
context.canvas.drawRect(rect.shift(offset), color);
|
||||
} else {
|
||||
context.canvas.drawPath(path.shift(offset), color);
|
||||
}
|
||||
void paintContents(PaintingContext context, Offset offset) {
|
||||
final closed = _closed;
|
||||
final transform = _closedTransform(rect);
|
||||
if (closed != null && transform != null) {
|
||||
_closedTransformLayer.layer = context.pushTransform(
|
||||
closed.needsCompositing,
|
||||
offset,
|
||||
transform,
|
||||
(context, offset) => context.paintChild(closed, offset),
|
||||
oldLayer: _closedTransformLayer.layer,
|
||||
);
|
||||
} else {
|
||||
_closedTransformLayer.layer = null;
|
||||
}
|
||||
context.paintChild(_open!, offset + rect.topLeft);
|
||||
}
|
||||
|
||||
final oldLayer = _clipLayer.layer;
|
||||
_clipLayer.layer = path == null
|
||||
? context.pushClipRect(
|
||||
needsCompositing,
|
||||
offset,
|
||||
rect,
|
||||
paintContents,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
oldLayer: oldLayer is ClipRectLayer ? oldLayer : null,
|
||||
)
|
||||
: context.pushClipPath(
|
||||
needsCompositing,
|
||||
offset,
|
||||
rect,
|
||||
path,
|
||||
paintContents,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
oldLayer: oldLayer is ClipPathLayer ? oldLayer : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
||||
final rect = _rect;
|
||||
if (rect == null) {
|
||||
return;
|
||||
}
|
||||
if (identical(child, _closed)) {
|
||||
final closedTransform = _closedTransform(rect);
|
||||
if (closedTransform != null) {
|
||||
transform.multiply(closedTransform);
|
||||
}
|
||||
return;
|
||||
}
|
||||
transform.translateByDouble(rect.left, rect.top, 0, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestSelf(Offset position) => true;
|
||||
|
||||
@override
|
||||
bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
||||
final rect = _rect;
|
||||
if (rect != null) {
|
||||
final shape = _route._shapeTween.lerp(_route._progress);
|
||||
final inside = shape == null
|
||||
? rect.contains(position)
|
||||
: shape.getOuterPath(rect).contains(position);
|
||||
if (!inside) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.hitTest(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
if (_route._fade == 0) {
|
||||
return false;
|
||||
}
|
||||
final origin = _rect?.topLeft ?? Offset.zero;
|
||||
return result.addWithPaintOffset(
|
||||
offset: origin,
|
||||
position: position,
|
||||
hitTest: (BoxHitTestResult result, Offset transformed) {
|
||||
return _open!.hitTest(result, position: transformed);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Slides pages by [slide] while they fade. A page fades by laying
|
||||
/// [color] over it wherever only that color lies beneath, since an opacity
|
||||
/// layer costs Impeller an offscreen pass of the page every frame.
|
||||
class SharedXPageTransition extends StatelessWidget {
|
||||
const SharedXPageTransition({
|
||||
super.key,
|
||||
required this.animation,
|
||||
required this.secondaryAnimation,
|
||||
required this.color,
|
||||
required this.slide,
|
||||
required this.overSettledPage,
|
||||
this.uncoveredByGesture = false,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
static const duration = Duration(milliseconds: 450);
|
||||
|
||||
/// Android 14's page slide, as FadeForwardsPageTransitionsBuilder has it.
|
||||
static const pageSlide = Offset(0.25, 0);
|
||||
|
||||
final Animation<double> animation;
|
||||
final Animation<double> secondaryAnimation;
|
||||
final Color color;
|
||||
|
||||
/// How far a page travels, as a fraction of its size.
|
||||
final Offset slide;
|
||||
|
||||
/// Whether the page below sat still as this one came in, so it fades out on
|
||||
/// this route's animation and leaves only [color] beneath.
|
||||
final bool overSettledPage;
|
||||
|
||||
/// Whether a drag on the page above drives [secondaryAnimation]: the page
|
||||
/// then trails the finger and fades in as far as it is uncovered, instead
|
||||
/// of showing only near the end of the drag.
|
||||
final bool uncoveredByGesture;
|
||||
final Widget child;
|
||||
|
||||
static final _slideCurve = CurveTween(curve: Curves.easeInOutCubicEmphasized);
|
||||
|
||||
static const _fadeInCurve = Interval(0.15, 0.55, curve: Curves.easeOutCubic);
|
||||
static const _fadeOutCurve = Interval(0, 0.3);
|
||||
static final _fadeIn = CurveTween(curve: _fadeInCurve);
|
||||
static final _fadeOut = Tween(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
).chain(CurveTween(curve: _fadeOutCurve));
|
||||
|
||||
// The fades cross a fifth of the way through, both pages a third opaque, and
|
||||
// the page on top shows only on its side of that point so they never double.
|
||||
static const _handOver = 0.2;
|
||||
static final _takeOver = CurveTween(
|
||||
curve: const _HandOver(_fadeInCurve, at: _handOver, leaving: false),
|
||||
);
|
||||
static final _handBack = Tween(begin: 1.0, end: 0.0).chain(
|
||||
CurveTween(
|
||||
curve: const _HandOver(_fadeOutCurve, at: _handOver, leaving: true),
|
||||
),
|
||||
);
|
||||
|
||||
Animatable<Offset> _slide(Offset begin, Offset end) {
|
||||
return Tween(begin: begin, end: end).chain(_slideCurve);
|
||||
}
|
||||
|
||||
Widget _overPageBelow(
|
||||
Animation<double> animation,
|
||||
Animatable<double> pageBelowOpacity,
|
||||
Widget child,
|
||||
) {
|
||||
final fades = !overSettledPage && animation.isAnimating;
|
||||
final opacity = fades ? pageBelowOpacity.evaluate(animation) : 1.0;
|
||||
return CustomPaint(
|
||||
painter: _Fill(color.withValues(alpha: color.a * (1 - opacity))),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enterSlide = _slide(slide, Offset.zero);
|
||||
final exitSlide = _slide(Offset.zero, slide);
|
||||
final gesture = uncoveredByGesture;
|
||||
final uncoverSlide = gesture
|
||||
? Tween(begin: -slide, end: Offset.zero)
|
||||
: _slide(-slide, Offset.zero);
|
||||
final coverSlide = gesture
|
||||
? Tween(begin: Offset.zero, end: -slide)
|
||||
: _slide(Offset.zero, -slide);
|
||||
final covered = DualTransitionBuilder(
|
||||
animation: ReverseAnimation(secondaryAnimation),
|
||||
forwardBuilder: (_, animation, child) => SlideTransition(
|
||||
position: uncoverSlide.animate(animation),
|
||||
child: _PageFade(
|
||||
opacity: gesture ? animation : _fadeIn.animate(animation),
|
||||
color: color,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
reverseBuilder: (_, animation, child) => SlideTransition(
|
||||
position: coverSlide.animate(animation),
|
||||
child: _PageFade(
|
||||
opacity: gesture
|
||||
? ReverseAnimation(animation)
|
||||
: _fadeOut.animate(animation),
|
||||
color: color,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
return DualTransitionBuilder(
|
||||
animation: animation,
|
||||
forwardBuilder: (_, animation, child) => _overPageBelow(
|
||||
animation,
|
||||
_fadeOut,
|
||||
SlideTransition(
|
||||
position: enterSlide.animate(animation),
|
||||
child: _PageFade(
|
||||
opacity: _takeOver.animate(animation),
|
||||
color: color,
|
||||
opaque: true,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
reverseBuilder: (_, animation, child) => IgnorePointer(
|
||||
ignoring: animation.status == AnimationStatus.forward,
|
||||
child: _overPageBelow(
|
||||
animation,
|
||||
_fadeIn,
|
||||
SlideTransition(
|
||||
position: exitSlide.animate(animation),
|
||||
child: _PageFade(
|
||||
opacity: _handBack.animate(animation),
|
||||
color: color,
|
||||
opaque: true,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: secondaryAnimation.isAnimating ? color : Colors.transparent,
|
||||
child: covered,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HandOver extends Curve {
|
||||
const _HandOver(this.curve, {required this.at, required this.leaving});
|
||||
|
||||
final Curve curve;
|
||||
final double at;
|
||||
final bool leaving;
|
||||
|
||||
@override
|
||||
double transformInternal(double t) {
|
||||
if (leaving) {
|
||||
return t < at ? curve.transform(t) : 1;
|
||||
}
|
||||
return t < at ? 0 : curve.transform(t);
|
||||
}
|
||||
}
|
||||
|
||||
class _Fill extends CustomPainter {
|
||||
const _Fill(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (color.a > 0) {
|
||||
canvas.drawRect(Offset.zero & size, Paint()..color = color);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Fill oldDelegate) => color != oldDelegate.color;
|
||||
}
|
||||
|
||||
/// Fades [child] toward [color] by painting that color over it, and under it
|
||||
/// too if [opaque], so nothing beneath shows through.
|
||||
class _PageFade extends SingleChildRenderObjectWidget {
|
||||
const _PageFade({
|
||||
required this.opacity,
|
||||
required this.color,
|
||||
this.opaque = false,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final Animation<double> opacity;
|
||||
final Color color;
|
||||
final bool opaque;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderPageFade(opacity: opacity, color: color, opaque: opaque);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, _RenderPageFade renderObject) {
|
||||
renderObject
|
||||
..opacity = opacity
|
||||
..color = color
|
||||
..opaque = opaque;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderPageFade extends RenderProxyBox {
|
||||
_RenderPageFade({
|
||||
required Animation<double> opacity,
|
||||
required Color color,
|
||||
required bool opaque,
|
||||
}) : _opacity = opacity,
|
||||
_color = color,
|
||||
_opaque = opaque;
|
||||
|
||||
Animation<double> _opacity;
|
||||
Color _color;
|
||||
bool _opaque;
|
||||
bool _visible = true;
|
||||
|
||||
set opacity(Animation<double> value) {
|
||||
if (identical(value, _opacity)) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_opacity.removeListener(_update);
|
||||
value.addListener(_update);
|
||||
}
|
||||
_opacity = value;
|
||||
_update();
|
||||
}
|
||||
|
||||
set color(Color value) {
|
||||
if (value == _color) {
|
||||
return;
|
||||
}
|
||||
_color = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
set opaque(bool value) {
|
||||
if (value == _opaque) {
|
||||
return;
|
||||
}
|
||||
_opaque = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
void _update() {
|
||||
final visible = _opacity.value > 0;
|
||||
if (visible != _visible) {
|
||||
_visible = visible;
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_opacity.addListener(_update);
|
||||
_update();
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_opacity.removeListener(_update);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final opacity = _opacity.value;
|
||||
if (child == null || opacity == 0) {
|
||||
return;
|
||||
}
|
||||
if (opacity == 1) {
|
||||
super.paint(context, offset);
|
||||
return;
|
||||
}
|
||||
if (_opaque) {
|
||||
context.canvas.drawRect(offset & size, Paint()..color = _color);
|
||||
}
|
||||
super.paint(context, offset);
|
||||
context.canvas.drawRect(
|
||||
offset & size,
|
||||
Paint()..color = _color.withValues(alpha: _color.a * (1 - opacity)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
|
||||
if (_visible) {
|
||||
super.visitChildrenForSemantics(visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
-29
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/color.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/widgets/deferred_push.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:fl_clash/widgets/navigator_resizable.dart';
|
||||
import 'package:fl_clash/widgets/page_transition.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';
|
||||
|
||||
Color _sheetColorOf(BuildContext context) {
|
||||
return SheetProvider.of(context)?.type == SheetType.bottomSheet
|
||||
@@ -15,15 +18,20 @@ Color _sheetColorOf(BuildContext context) {
|
||||
: ColorScheme.of(context).surface;
|
||||
}
|
||||
|
||||
bool _stacksPages(BuildContext context) => SheetStackScope.of(context) != null;
|
||||
|
||||
class PagedSheetRoute<T> extends PageRoute<T>
|
||||
with ObservableRouteMixin<T>, DragBackRouteMixin<T> {
|
||||
with
|
||||
DragBackRouteMixin<T>,
|
||||
ResizableNavigatorRouteMixin<T>,
|
||||
DeferredPushRouteMixin<T> {
|
||||
PagedSheetRoute({
|
||||
super.settings,
|
||||
super.fullscreenDialog,
|
||||
super.allowSnapshotting,
|
||||
super.requestFocus,
|
||||
this.maintainState = true,
|
||||
this.duration = const Duration(milliseconds: 350),
|
||||
this.duration = SharedXPageTransition.duration,
|
||||
this.backgroundColor,
|
||||
this.transitionsBuilder,
|
||||
required this.builder,
|
||||
@@ -32,6 +40,8 @@ class PagedSheetRoute<T> extends PageRoute<T>
|
||||
final WidgetBuilder builder;
|
||||
|
||||
ScrollController? _sheetScroll;
|
||||
CurvedAnimation? _stackEntrance;
|
||||
CurvedAnimation? _stackCover;
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
@@ -40,12 +50,39 @@ class PagedSheetRoute<T> extends PageRoute<T>
|
||||
final Color? backgroundColor;
|
||||
final RouteTransitionsBuilder? transitionsBuilder;
|
||||
|
||||
/// Whether the page below sat still as this one came in, so it fades out on
|
||||
/// this route's animation and leaves only the sheet's surface beneath.
|
||||
bool _overSettledPage = false;
|
||||
|
||||
@override
|
||||
void didChangePrevious(Route<dynamic>? previousRoute) {
|
||||
super.didChangePrevious(previousRoute);
|
||||
_overSettledPage =
|
||||
previousRoute is PagedSheetRoute &&
|
||||
previousRoute.transitionsBuilder == null &&
|
||||
previousRoute.animation!.isCompleted &&
|
||||
previousRoute.secondaryAnimation!.isDismissed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sheetScroll?.dispose();
|
||||
_stackEntrance?.dispose();
|
||||
_stackCover?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A stacked page leaves the one it covers painted, peeking above it.
|
||||
@override
|
||||
bool get opaque {
|
||||
final context = navigator?.context;
|
||||
return context == null || !_stacksPages(context);
|
||||
}
|
||||
|
||||
double _stackInset(BuildContext context) {
|
||||
return _stacksPages(context) && !isFirst ? stackedSheetPeek : 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@@ -80,16 +117,23 @@ class PagedSheetRoute<T> extends PageRoute<T>
|
||||
}
|
||||
final sheetScroll = _sheetScroll;
|
||||
final page = builder(context);
|
||||
final inset = _stackInset(context);
|
||||
final content = sheetScroll == null
|
||||
? page
|
||||
: PrimaryScrollController(
|
||||
controller: sheetScroll,
|
||||
automaticallyInheritForPlatforms: TargetPlatform.values.toSet(),
|
||||
child: page,
|
||||
);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: ResizableNavigatorRouteContentBoundary(
|
||||
child: sheetScroll == null
|
||||
? page
|
||||
: PrimaryScrollController(
|
||||
controller: sheetScroll,
|
||||
automaticallyInheritForPlatforms: TargetPlatform.values.toSet(),
|
||||
child: page,
|
||||
child: resizableContent(
|
||||
inset == 0
|
||||
? content
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(top: inset),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -103,18 +147,165 @@ class PagedSheetRoute<T> extends PageRoute<T>
|
||||
Widget 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),
|
||||
});
|
||||
if (transitionsBuilder == null && _stacksPages(context)) {
|
||||
return dragBackDetector(
|
||||
_buildStackTransitions(
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
color,
|
||||
child,
|
||||
),
|
||||
);
|
||||
}
|
||||
final dragging = isDragBackActive;
|
||||
final pageAnimation = dragging ? kAlwaysCompleteAnimation : animation;
|
||||
final pageSecondaryAnimation = dragging
|
||||
? kAlwaysDismissedAnimation
|
||||
: secondaryAnimation;
|
||||
final page = ColoredBox(
|
||||
color: dragging ? color : Colors.transparent,
|
||||
child: child,
|
||||
);
|
||||
return dragBackDetector(
|
||||
dragBackSlide(context, animation, switch (transitionsBuilder) {
|
||||
final builder? => builder(
|
||||
context,
|
||||
pageAnimation,
|
||||
pageSecondaryAnimation,
|
||||
page,
|
||||
),
|
||||
null => SharedXPageTransition(
|
||||
animation: pageAnimation,
|
||||
secondaryAnimation: pageSecondaryAnimation,
|
||||
color: color,
|
||||
slide: SharedXPageTransition.pageSlide,
|
||||
overSettledPage: _overSettledPage,
|
||||
uncoveredByGesture: navigator!.userGestureInProgress,
|
||||
child: page,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStackTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Color color,
|
||||
Widget child,
|
||||
) {
|
||||
final cover = navigator!.userGestureInProgress
|
||||
? secondaryAnimation.value
|
||||
: (_stackCover ??= CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.linearToEaseOut,
|
||||
reverseCurve: Curves.easeInToLinear,
|
||||
)).value;
|
||||
final inset = _stackInset(context);
|
||||
final scrim = ColorScheme.of(context).modalScrim;
|
||||
final card = Transform.scale(
|
||||
scale: 1 - (1 - stackedSheetScale) * cover,
|
||||
alignment: Alignment.topCenter,
|
||||
origin: Offset(0, inset),
|
||||
child: _StackedPageCard(
|
||||
inset: inset,
|
||||
color: color,
|
||||
// Half the scrim under the sheet, so the covered page's edge stays
|
||||
// apart from what that scrim dims.
|
||||
scrim: scrim.withValues(alpha: scrim.a * cover / 2),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
final entrance = isDragBackActive
|
||||
? 1.0
|
||||
: (_stackEntrance ??= CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped,
|
||||
)).value;
|
||||
return dragBackSlide(
|
||||
context,
|
||||
animation,
|
||||
FractionalTranslation(translation: Offset(0, 1 - entrance), child: card),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints down to the bottom of the sheet, under content shorter than it.
|
||||
class _StackedPageCard extends StatelessWidget {
|
||||
const _StackedPageCard({
|
||||
required this.inset,
|
||||
required this.color,
|
||||
required this.scrim,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double inset;
|
||||
final Color color;
|
||||
final Color scrim;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = AppRadius.top(AppCorner.xxl);
|
||||
return CustomPaint(
|
||||
painter: _StackedPagePainter(inset: inset, radius: radius, color: color),
|
||||
foregroundPainter: scrim.a == 0
|
||||
? null
|
||||
: _StackedPagePainter(inset: inset, radius: radius, color: scrim),
|
||||
child: ClipRSuperellipse(
|
||||
clipper: _StackedPageClipper(inset: inset, radius: radius),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RSuperellipse _stackedPageShape(Size size, double inset, BorderRadius radius) {
|
||||
return radius.toRSuperellipse(
|
||||
Rect.fromLTRB(0, inset, size.width, size.height),
|
||||
);
|
||||
}
|
||||
|
||||
class _StackedPagePainter extends CustomPainter {
|
||||
const _StackedPagePainter({
|
||||
required this.inset,
|
||||
required this.radius,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final double inset;
|
||||
final BorderRadius radius;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.drawRSuperellipse(
|
||||
_stackedPageShape(size, inset, radius),
|
||||
Paint()..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_StackedPagePainter oldDelegate) =>
|
||||
inset != oldDelegate.inset ||
|
||||
radius != oldDelegate.radius ||
|
||||
color != oldDelegate.color;
|
||||
}
|
||||
|
||||
class _StackedPageClipper extends CustomClipper<RSuperellipse> {
|
||||
const _StackedPageClipper({required this.inset, required this.radius});
|
||||
|
||||
final double inset;
|
||||
final BorderRadius radius;
|
||||
|
||||
@override
|
||||
RSuperellipse getClip(Size size) => _stackedPageShape(size, inset, radius);
|
||||
|
||||
@override
|
||||
bool shouldReclip(_StackedPageClipper oldClipper) =>
|
||||
inset != oldClipper.inset || radius != oldClipper.radius;
|
||||
}
|
||||
|
||||
class PagedSheet extends StatelessWidget {
|
||||
@@ -135,9 +326,11 @@ class PagedSheet extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = SheetProvider.of(context)?.type;
|
||||
return Material(
|
||||
final surface = color ?? _sheetColorOf(context);
|
||||
final stacks = _stacksPages(context);
|
||||
final sheet = Material(
|
||||
animationDuration: Duration.zero,
|
||||
color: color ?? _sheetColorOf(context),
|
||||
color: stacks ? Colors.transparent : surface,
|
||||
shape:
|
||||
shape ??
|
||||
(type == SheetType.bottomSheet
|
||||
@@ -146,14 +339,65 @@ class PagedSheet extends StatelessWidget {
|
||||
clipBehavior: clipBehavior,
|
||||
child: NavigatorResizable(child: child),
|
||||
);
|
||||
if (!stacks) {
|
||||
return sheet;
|
||||
}
|
||||
// The pages paint the surface, so this fills the space a drag past the
|
||||
// tallest detent stretches below them.
|
||||
return CustomPaint(
|
||||
painter: _SkirtPainter(
|
||||
color: surface,
|
||||
depth: MediaQuery.heightOf(context),
|
||||
),
|
||||
child: sheet,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedPagedSheetProps = SheetProps(
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
maxWidth: double.maxFinite,
|
||||
);
|
||||
class _SkirtPainter extends CustomPainter {
|
||||
const _SkirtPainter({required this.color, required this.depth});
|
||||
|
||||
final Color color;
|
||||
final double depth;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, size.height, size.width, depth),
|
||||
Paint()..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SkirtPainter oldDelegate) =>
|
||||
color != oldDelegate.color || depth != oldDelegate.depth;
|
||||
}
|
||||
|
||||
/// Pushes the page into the sheet [context] is a page of, and opens it with
|
||||
/// [showExtend] anywhere else.
|
||||
Future<T?> showSheetPageOrExtend<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
}) {
|
||||
if (isSheetPage(context)) {
|
||||
return Navigator.of(context).push(PagedSheetRoute<T>(builder: builder));
|
||||
}
|
||||
return showExtend<T>(context, builder: builder);
|
||||
}
|
||||
|
||||
/// Pushes the page into the sheet [context] is a page of, and opens a sheet
|
||||
/// for it anywhere else.
|
||||
Future<T?> showInSheet<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
}) {
|
||||
if (isSheetPage(context)) {
|
||||
return Navigator.of(context).push(PagedSheetRoute<T>(builder: builder));
|
||||
}
|
||||
return showSheet<T>(context: context, builder: builder);
|
||||
}
|
||||
|
||||
const nestedPagedSheetProps = SheetProps(backgroundColor: Colors.transparent);
|
||||
|
||||
/// 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
|
||||
@@ -177,10 +421,16 @@ class NestedPagedSheet extends StatefulWidget {
|
||||
class _NestedPagedSheetState extends State<NestedPagedSheet> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey();
|
||||
ValueNotifier<SheetDismissHandler?>? _dismissHandler;
|
||||
ValueNotifier<bool>? _surfaceClaimed;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final surfaceClaimed = SheetStackScope.of(context);
|
||||
if (!identical(surfaceClaimed, _surfaceClaimed)) {
|
||||
_surfaceClaimed?.value = false;
|
||||
_surfaceClaimed = surfaceClaimed?..value = true;
|
||||
}
|
||||
final handler = SheetDismissScope.of(context);
|
||||
if (identical(handler, _dismissHandler)) {
|
||||
return;
|
||||
@@ -191,6 +441,7 @@ class _NestedPagedSheetState extends State<NestedPagedSheet> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_surfaceClaimed?.value = false;
|
||||
_releaseDismiss();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -242,11 +493,11 @@ class _NestedPagedSheetState extends State<NestedPagedSheet> {
|
||||
},
|
||||
child: sheetProvider.copyWith(
|
||||
nestedNavigatorPop: ([data]) => Navigator.of(context).pop(data),
|
||||
onClose: _handlePop,
|
||||
// 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: [
|
||||
|
||||
+53
-15
@@ -211,6 +211,8 @@ class CommonPopupRoute<T> extends PopupRoute<T> {
|
||||
|
||||
final _unfolded = ValueNotifier<bool>(false);
|
||||
|
||||
final _pin = _PopupPin();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_grow.dispose();
|
||||
@@ -276,6 +278,7 @@ class CommonPopupRoute<T> extends PopupRoute<T> {
|
||||
safeInsets: safeInsets,
|
||||
placement: placement,
|
||||
avoid: avoid,
|
||||
pin: _pin,
|
||||
),
|
||||
child: _PopupMorph(
|
||||
anchor: anchor,
|
||||
@@ -705,6 +708,7 @@ class _PopupLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
required this.anchor,
|
||||
required this.safeInsets,
|
||||
required this.placement,
|
||||
required this.pin,
|
||||
this.avoid,
|
||||
});
|
||||
|
||||
@@ -712,6 +716,7 @@ class _PopupLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
final EdgeInsets safeInsets;
|
||||
final PopupPlacement placement;
|
||||
final Rect? avoid;
|
||||
final _PopupPin pin;
|
||||
|
||||
EdgeInsets get _insets => safeInsets + const EdgeInsets.all(_screenMargin);
|
||||
|
||||
@@ -734,20 +739,27 @@ class _PopupLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
final insets = _insets;
|
||||
final maxX = size.width - insets.right - childSize.width;
|
||||
final maxY = size.height - insets.bottom - childSize.height;
|
||||
final (x, y) = switch (placement) {
|
||||
PopupPlacement.overAnchorEnd => (
|
||||
anchor.right - childSize.width,
|
||||
anchor.top - _anchorOverlap,
|
||||
),
|
||||
PopupPlacement.belowPoint => (
|
||||
anchor.left,
|
||||
_yBelowPoint(childSize.height, insets.top, maxY),
|
||||
),
|
||||
};
|
||||
return Offset(
|
||||
final pinned = pin.topRightFor(this, size);
|
||||
final (x, y) = pinned != null
|
||||
? (pinned.dx - childSize.width, pinned.dy)
|
||||
: switch (placement) {
|
||||
PopupPlacement.overAnchorEnd => (
|
||||
anchor.right - childSize.width,
|
||||
anchor.top - _anchorOverlap,
|
||||
),
|
||||
PopupPlacement.belowPoint => (
|
||||
anchor.left,
|
||||
_yBelowPoint(childSize.height, insets.top, maxY),
|
||||
),
|
||||
};
|
||||
final position = Offset(
|
||||
x.clamp(insets.left, math.max(insets.left, maxX)),
|
||||
y.clamp(insets.top, math.max(insets.top, maxY)),
|
||||
);
|
||||
if (pinned == null) {
|
||||
pin.hold(this, size, position.translate(childSize.width, 0));
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
double _yBelowPoint(double height, double minY, double maxY) {
|
||||
@@ -768,12 +780,38 @@ class _PopupLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
return anchor.bottom;
|
||||
}
|
||||
|
||||
bool _placesLike(_PopupLayoutDelegate other) {
|
||||
return other.anchor == anchor &&
|
||||
other.safeInsets == safeInsets &&
|
||||
other.placement == placement &&
|
||||
other.avoid == avoid;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_PopupLayoutDelegate oldDelegate) {
|
||||
return oldDelegate.anchor != anchor ||
|
||||
oldDelegate.safeInsets != safeInsets ||
|
||||
oldDelegate.placement != placement ||
|
||||
oldDelegate.avoid != avoid;
|
||||
return !_placesLike(oldDelegate) || oldDelegate.pin != pin;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the top right corner the menu levels stack from while a sub menu
|
||||
/// unfolds, so the card neither slides nor flips around what it avoids.
|
||||
class _PopupPin {
|
||||
_PopupLayoutDelegate? _placement;
|
||||
Size _size = Size.zero;
|
||||
Offset _topRight = Offset.zero;
|
||||
|
||||
Offset? topRightFor(_PopupLayoutDelegate placement, Size size) {
|
||||
final held = _placement;
|
||||
if (held == null || size != _size || !held._placesLike(placement)) {
|
||||
return null;
|
||||
}
|
||||
return _topRight;
|
||||
}
|
||||
|
||||
void hold(_PopupLayoutDelegate placement, Size size, Offset topRight) {
|
||||
_placement = placement;
|
||||
_size = size;
|
||||
_topRight = topRight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/navigator.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Holds list refreshes while the page's route is pushed, popped, covered,
|
||||
@@ -66,3 +70,37 @@ mixin RouteMotionHoldMixin<T extends StatefulWidget> on State<T> {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls [didSettleRoute] once the route that brought the page in has settled.
|
||||
mixin RouteSettledMixin<T extends StatefulWidget> on State<T> {
|
||||
var _settleScheduled = false;
|
||||
var _routeSettled = false;
|
||||
|
||||
bool get routeSettled => _routeSettled;
|
||||
|
||||
@protected
|
||||
void didSettleRoute();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_settleScheduled) {
|
||||
return;
|
||||
}
|
||||
_settleScheduled = true;
|
||||
unawaited(_settle());
|
||||
}
|
||||
|
||||
Future<void> _settle() async {
|
||||
await whenRouteSettled(context);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_routeSettled = true;
|
||||
didSettleRoute();
|
||||
}
|
||||
}
|
||||
|
||||
ScrollCacheExtent? arrivalScrollCacheExtent(bool routeSettled) {
|
||||
return routeSettled ? null : const ScrollCacheExtent.pixels(0);
|
||||
}
|
||||
|
||||
+160
-39
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
@@ -18,6 +20,8 @@ import 'popup.dart';
|
||||
import 'search_field.dart';
|
||||
import 'sheet.dart';
|
||||
import 'sheet_header.dart';
|
||||
import 'sheet_navigator.dart';
|
||||
import 'sortable.dart';
|
||||
|
||||
typedef OnKeywordsUpdateCallback = void Function(List<String> keywords);
|
||||
|
||||
@@ -52,12 +56,16 @@ class CommonScaffold extends StatefulWidget {
|
||||
final AppBarSearchState? searchState;
|
||||
final List<IconButtonData> searchActions;
|
||||
|
||||
/// One button group taking a single bar slot, searching or not; never folds.
|
||||
/// Shown searching or not and never folded; the first two share a button
|
||||
/// group and any after them stand alone.
|
||||
final List<IconButtonData> selectionActions;
|
||||
final OnKeywordsUpdateCallback? onKeywordsUpdate;
|
||||
final bool? resizeToAvoidBottomInset;
|
||||
final VoidCallback? backAction;
|
||||
|
||||
/// Puts the toggle of the sort mode in the bar.
|
||||
final bool canSort;
|
||||
|
||||
const CommonScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
@@ -80,6 +88,7 @@ class CommonScaffold extends StatefulWidget {
|
||||
this.isTV,
|
||||
this.onKeywordsUpdate,
|
||||
this.resizeToAvoidBottomInset,
|
||||
this.canSort = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -93,6 +102,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
final ValueNotifier<List<String>> _keywordsNotifier = ValueNotifier([]);
|
||||
final _textController = TextEditingController();
|
||||
final _searchFocusNode = FocusNode();
|
||||
var _sorting = false;
|
||||
|
||||
bool get _isSearch {
|
||||
return _appBarState.value.searchState?.query != null;
|
||||
@@ -107,7 +117,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
}
|
||||
|
||||
bool get _hasActions {
|
||||
return _appBarState.value.searchState != null ||
|
||||
return widget.canSort ||
|
||||
widget.primaryAction != null ||
|
||||
widget.iconActions.isNotEmpty ||
|
||||
widget.menuItems.isNotEmpty ||
|
||||
@@ -134,10 +144,17 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
_updateSearchState((state) => state?.copyWith(query: ''));
|
||||
}
|
||||
|
||||
void _toggleSorting() {
|
||||
setState(() {
|
||||
_sorting = !_sorting;
|
||||
});
|
||||
}
|
||||
|
||||
bool _handleSearchShortcut(KeyEvent event) {
|
||||
if (event is! KeyDownEvent ||
|
||||
_appBarState.value.searchState == null ||
|
||||
_isEdit ||
|
||||
_sorting ||
|
||||
!controlSingleActivator(
|
||||
LogicalKeyboardKey.keyF,
|
||||
).accepts(event, HardwareKeyboard.instance) ||
|
||||
@@ -220,6 +237,9 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
if (oldWidget.isLoading != widget.isLoading) {
|
||||
_loadingNotifier.value = widget.isLoading;
|
||||
}
|
||||
if (oldWidget.canSort && !widget.canSort) {
|
||||
_sorting = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleClearInput() {
|
||||
@@ -250,6 +270,9 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
if (_isEdit) {
|
||||
_appBarState.value.editState?.onExit();
|
||||
}
|
||||
if (_sorting) {
|
||||
_toggleSorting();
|
||||
}
|
||||
}
|
||||
|
||||
void _popAppBarLayer() {
|
||||
@@ -332,7 +355,9 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
data: useCloseIcon
|
||||
? IconButtonData(
|
||||
glyph: AppGlyphs.close,
|
||||
onPressed: context.safeNestedPop,
|
||||
onPressed:
|
||||
SheetProvider.of(context)?.onClose ??
|
||||
() => context.safeNestedPop(),
|
||||
tooltip: appLocalizations.close,
|
||||
)
|
||||
: IconButtonData(
|
||||
@@ -345,7 +370,26 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
|
||||
Widget? _buildLeading(VoidCallback? backAction, {required _SheetPop? pop}) {
|
||||
final button = _buildLeadingButton(backAction, pop: pop);
|
||||
return button == null ? null : Center(child: ElasticPress(child: button));
|
||||
if (button == null) {
|
||||
return null;
|
||||
}
|
||||
final hidden = _sorting && !_isEdit && !_isSearch;
|
||||
return Center(
|
||||
child: ExcludeFocus(
|
||||
excluding: hidden,
|
||||
child: ExcludeSemantics(
|
||||
excluding: hidden,
|
||||
child: IgnorePointer(
|
||||
ignoring: hidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity: hidden ? 0 : 1,
|
||||
duration: context.motionDuration(kThemeAnimationDuration),
|
||||
child: ElasticPress(child: button),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildLeadingButton(
|
||||
@@ -422,6 +466,20 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
VoidCallback? backAction,
|
||||
_SheetForm form,
|
||||
) {
|
||||
if (_sorting) {
|
||||
return genActions([
|
||||
if (widget.canSort)
|
||||
ElasticPress(
|
||||
child: AppBarActionButton(
|
||||
data: sortModeAction(
|
||||
context,
|
||||
sorting: true,
|
||||
onPressed: _toggleSorting,
|
||||
),
|
||||
),
|
||||
),
|
||||
], edge: AppBarActionEdge.container);
|
||||
}
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final pop = form.pop;
|
||||
final lead = _isSearch
|
||||
@@ -441,7 +499,10 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
? const <Widget>[]
|
||||
: widget.actions ?? const <Widget>[];
|
||||
final selection = widget.selectionActions;
|
||||
final selectionCount = selection.isEmpty ? 0 : 1;
|
||||
final selectionGroup = selection.take(_maxGroupedActions);
|
||||
final selectionSingles = selection.skip(_maxGroupedActions);
|
||||
final selectionCount =
|
||||
(selection.isEmpty ? 0 : 1) + selectionSingles.length;
|
||||
final fold = _isSearch
|
||||
? _foldBarActions(
|
||||
hasLead: true,
|
||||
@@ -452,7 +513,15 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
hasLead: lead != null,
|
||||
primary: primaryAction,
|
||||
foldPrimary: widget.foldPrimaryAction,
|
||||
icons: widget.iconActions,
|
||||
icons: [
|
||||
if (widget.canSort && !_isEdit)
|
||||
sortModeAction(
|
||||
context,
|
||||
sorting: false,
|
||||
onPressed: _toggleSorting,
|
||||
),
|
||||
...widget.iconActions,
|
||||
],
|
||||
widgetCount: widgets.length + selectionCount,
|
||||
menuItems: widget.menuItems,
|
||||
);
|
||||
@@ -474,9 +543,14 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
if (selection.isNotEmpty)
|
||||
TonalButtonGroup(
|
||||
children: [
|
||||
for (final data in selection) AppBarActionButton(data: data),
|
||||
for (final data in selectionGroup) AppBarActionButton(data: data),
|
||||
],
|
||||
),
|
||||
for (final data in selectionSingles)
|
||||
ElasticPress(
|
||||
enabled: !data.isLoading && data.onPressed != null,
|
||||
child: AppBarActionButton(data: data),
|
||||
),
|
||||
for (final action in widgets) ElasticPress(child: action),
|
||||
if (fold.overflow.isNotEmpty)
|
||||
ElasticPress(child: _OverflowMenuButton(items: fold.overflow)),
|
||||
@@ -494,7 +568,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
final appBar = TonalButtonTheme(
|
||||
child: _isSearch ? _buildSearchingAppBarTheme(child) : child,
|
||||
);
|
||||
if (_isEdit || _isSearch) {
|
||||
if (_isEdit || _isSearch || _sorting) {
|
||||
return BackLayerScope(onBack: _handleExitAppBarLayer, child: appBar);
|
||||
}
|
||||
return appBar;
|
||||
@@ -516,6 +590,8 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
required IconButtonData? primaryAction,
|
||||
}) {
|
||||
final isBottomSheet = form.isBottomSheet;
|
||||
// Under a sheet the bar drops its actions, so none reads as the sheet's.
|
||||
final uncovered = ReverseAnimation(sheetCoverOf(context));
|
||||
return PreferredSize(
|
||||
preferredSize: Size.fromHeight(
|
||||
isBottomSheet ? sheetToolbarHeight : pageToolbarHeight,
|
||||
@@ -541,15 +617,22 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
: null,
|
||||
leading: _buildLeading(backAction, pop: form.pop),
|
||||
title: _buildTitle(state.searchState),
|
||||
actions: _buildActions(
|
||||
state.searchState != null,
|
||||
primaryAction,
|
||||
backAction,
|
||||
form,
|
||||
),
|
||||
actions: [
|
||||
for (final action in _buildActions(
|
||||
state.searchState != null,
|
||||
primaryAction,
|
||||
backAction,
|
||||
form,
|
||||
))
|
||||
FadeTransition(opacity: uncovered, child: action),
|
||||
],
|
||||
),
|
||||
);
|
||||
return isBottomSheet ? appBar : _buildFloatingHeader(appBar);
|
||||
return isBottomSheet
|
||||
? appBar
|
||||
: _buildFloatingHeader(
|
||||
_WindowControlsClearance(child: appBar),
|
||||
);
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
@@ -595,6 +678,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
// The page's bar floats over its body, which leaves the bar's height at
|
||||
// its top through MediaQuery padding, as extendBodyBehindAppBar does.
|
||||
final barFloats = !isBottomSheet;
|
||||
final sortableBody = SortModeScope(sorting: _sorting, child: widget.body);
|
||||
final body = SafeArea(
|
||||
top: !barFloats,
|
||||
child: ValueListenableBuilder(
|
||||
@@ -605,7 +689,8 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
widget.onKeywordsUpdate!(keywords);
|
||||
});
|
||||
}
|
||||
final hasHeader = keywords.isNotEmpty || (isTV && fabSlot != null);
|
||||
final tvFab = isTV && !_sorting ? fabSlot : null;
|
||||
final hasHeader = keywords.isNotEmpty || tvFab != null;
|
||||
final barInset = MediaQuery.paddingOf(context).top;
|
||||
final overlap = barFloats
|
||||
? barInset
|
||||
@@ -617,12 +702,12 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
mainAxisSize: hugsContent ? MainAxisSize.min : MainAxisSize.max,
|
||||
children: [
|
||||
if (hasHeader) SizedBox(height: barInset),
|
||||
if (isTV && fabSlot != null)
|
||||
if (tvFab != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: CommonScaffoldFabExtendedProvider(
|
||||
isExtended: true,
|
||||
child: fabSlot,
|
||||
child: tvFab,
|
||||
),
|
||||
),
|
||||
if (keywords.isNotEmpty)
|
||||
@@ -653,9 +738,9 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
? MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: FloatingBarScope(inset: 0, child: widget.body),
|
||||
child: FloatingBarScope(inset: 0, child: sortableBody),
|
||||
)
|
||||
: widget.body,
|
||||
: sortableBody,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -712,7 +797,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
return true;
|
||||
},
|
||||
);
|
||||
final fab = hasFab
|
||||
final fab = hasFab && !_sorting
|
||||
? SheetOverhangLift(
|
||||
child: bottomInset > 0
|
||||
? Padding(
|
||||
@@ -738,7 +823,7 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
backgroundColor:
|
||||
widget.backgroundColor ?? context.colorScheme.surfaceContainerLow,
|
||||
header: SheetToolBar(appBar: appBar),
|
||||
footer: form.dockedSearch == null
|
||||
footer: form.dockedSearch == null || _sorting
|
||||
? null
|
||||
: _buildDockedSearch(form.dockedSearch!),
|
||||
body: content,
|
||||
@@ -758,27 +843,13 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
borderRadius: AppRadius.top(AppCorner.xxl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(child: sheetBody),
|
||||
const _KeyboardSpacer(),
|
||||
if (!form.hasDockedSearch)
|
||||
SizedBox(height: MediaQuery.viewPaddingOf(context).bottom),
|
||||
],
|
||||
children: [Flexible(child: sheetBody)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyboardSpacer extends StatelessWidget {
|
||||
const _KeyboardSpacer();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(height: MediaQuery.viewInsetsOf(context).bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds a body whose top stays put clear of the bar floating over it.
|
||||
class AppBarClearance extends StatelessWidget {
|
||||
const AppBarClearance({super.key, required this.child});
|
||||
@@ -935,6 +1006,7 @@ _foldBarActions({
|
||||
CommonPopupMenuItem(
|
||||
glyph: data.glyph,
|
||||
label: data.tooltip ?? '',
|
||||
checked: data.isSelected,
|
||||
onPressed: data.isLoading ? null : data.onPressed,
|
||||
),
|
||||
...menuItems,
|
||||
@@ -969,8 +1041,16 @@ class AppBarActionButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
return IconButton(
|
||||
tooltip: data.tooltip,
|
||||
isSelected: data.isSelected,
|
||||
style: data.isSelected == true
|
||||
? IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
)
|
||||
: null,
|
||||
onPressed: data.isLoading ? null : data.onPressed,
|
||||
icon: data.isLoading
|
||||
? SizedBox.square(
|
||||
@@ -1012,7 +1092,12 @@ class _SheetForm {
|
||||
dockedSearch: isBottomSheet ? searchState : null,
|
||||
pop: !isModal
|
||||
? null
|
||||
: _SheetPop.of(context, provider, hasActions: hasActions),
|
||||
: _SheetPop.of(
|
||||
context,
|
||||
provider,
|
||||
// A bottom sheet docks its search at its foot, not in the bar.
|
||||
hasActions: hasActions || searchState != null && !isBottomSheet,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1083,7 +1168,7 @@ enum AppBarActionEdge {
|
||||
};
|
||||
}
|
||||
|
||||
/// Only the touch platforms ask for the 48px target that pads the container out.
|
||||
/// Only the touch platforms ask for the 48 dp target that pads the container out.
|
||||
double _tapPaddingOf(BuildContext context) =>
|
||||
Theme.of(context).materialTapTargetSize == MaterialTapTargetSize.padded
|
||||
? _iconButtonTapPadding
|
||||
@@ -1134,6 +1219,42 @@ class AppBarInsetScope extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A route over the home page on the root navigator fills the window, with
|
||||
/// neither the window header nor the sidebar keeping it off the buttons.
|
||||
class _WindowControlsClearance extends ConsumerWidget {
|
||||
const _WindowControlsClearance({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
static bool _fillsWindow(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
return route != null &&
|
||||
route.opaque &&
|
||||
!route.isFirst &&
|
||||
route.navigator == Navigator.maybeOf(context, rootNavigator: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controls = windowControlsOverPage(
|
||||
isMacOS: system.isMacOS,
|
||||
version: ref.watch(versionProvider),
|
||||
isMobileView: ref.watch(isMobileViewProvider),
|
||||
);
|
||||
if (controls.isEmpty || !_fillsWindow(context)) {
|
||||
return child;
|
||||
}
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final padding = mediaQuery.padding;
|
||||
return MediaQuery(
|
||||
data: mediaQuery.copyWith(
|
||||
padding: padding.copyWith(left: math.max(padding.left, controls.width)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BaseScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> actions;
|
||||
|
||||
+115
-151
@@ -71,6 +71,7 @@ class CommonScrollBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding = this.padding + EdgeInsets.only(top: _barClearance(context));
|
||||
if (padding == EdgeInsets.zero) {
|
||||
return _buildScrollBar(context, child);
|
||||
}
|
||||
@@ -87,6 +88,13 @@ class CommonScrollBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// What of the floating bar over the scroll view MediaQuery padding leaves to
|
||||
/// cover: a page's bar pads its body, a bottom sheet's header does not.
|
||||
double _barClearance(BuildContext context) {
|
||||
final overlap = FloatingBarScope.of(context) ?? 0;
|
||||
return max(0.0, overlap - MediaQuery.paddingOf(context).top);
|
||||
}
|
||||
|
||||
/// The ambient behavior less the bar it would stack on the scrollable that
|
||||
/// [controller] drives, so lists nested inside keep theirs.
|
||||
class _OwnBarScrollBehavior extends ScrollBehavior {
|
||||
@@ -177,6 +185,9 @@ class _AppScrollbar extends RawScrollbar {
|
||||
class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
late final AnimationController _emphasis;
|
||||
late final CurvedAnimation _emphasisCurve;
|
||||
late final AnimationController _presence;
|
||||
ValueListenable<bool>? _sheetOpen;
|
||||
var _hidden = false;
|
||||
late ColorScheme _colorScheme;
|
||||
late TargetPlatform _platform;
|
||||
var _hovered = false;
|
||||
@@ -185,6 +196,7 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
Offset? _scrubLast;
|
||||
Offset? _scrubPosition;
|
||||
var _scrubRate = 1.0;
|
||||
ScrubbableScrollPosition? _scrubbed;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -194,6 +206,11 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
duration: const Duration(milliseconds: 120),
|
||||
)..addListener(updateScrollbarPainter);
|
||||
_emphasisCurve = CurvedAnimation(parent: _emphasis, curve: Curves.easeOut);
|
||||
_presence = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.fadeDuration,
|
||||
value: 1,
|
||||
)..addListener(updateScrollbarPainter);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -202,15 +219,76 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
_colorScheme = theme.colorScheme;
|
||||
_platform = theme.platform;
|
||||
super.didChangeDependencies();
|
||||
_watchSheet();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_AppScrollbar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_watchSheet();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_endScrub();
|
||||
_sheetOpen?.removeListener(_syncSheet);
|
||||
_presence.dispose();
|
||||
_emphasisCurve.dispose();
|
||||
_emphasis.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get enableGestures => super.enableGestures && !_hidden;
|
||||
|
||||
// A thumb already held keeps its drag, or its scrub would never end.
|
||||
bool get _sheetHides => !_dragging && !(_sheetOpen?.value ?? true);
|
||||
|
||||
void _watchSheet() {
|
||||
final controller =
|
||||
widget.controller ?? PrimaryScrollController.maybeOf(context);
|
||||
final sheetOpen = controller is SheetScrollController
|
||||
? controller.sheetOpen
|
||||
: null;
|
||||
if (sheetOpen == _sheetOpen) {
|
||||
return;
|
||||
}
|
||||
_sheetOpen?.removeListener(_syncSheet);
|
||||
_sheetOpen = sheetOpen?..addListener(_syncSheet);
|
||||
_hidden = _sheetHides;
|
||||
_presence.value = _hidden ? 0 : 1;
|
||||
}
|
||||
|
||||
void _syncSheet() {
|
||||
final hidden = _sheetHides;
|
||||
if (hidden == _hidden) {
|
||||
return;
|
||||
}
|
||||
setState(() => _hidden = hidden);
|
||||
if (hidden) {
|
||||
_presence.reverse();
|
||||
} else {
|
||||
_presence.forward();
|
||||
}
|
||||
}
|
||||
|
||||
void _beginScrub() {
|
||||
final controller =
|
||||
widget.controller ?? PrimaryScrollController.maybeOf(context);
|
||||
if (controller == null || controller.positions.length != 1) {
|
||||
return;
|
||||
}
|
||||
final position = controller.position;
|
||||
if (position is ScrubbableScrollPosition) {
|
||||
_scrubbed = position..isScrubbing = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _endScrub() {
|
||||
_scrubbed?.isScrubbing = false;
|
||||
_scrubbed = null;
|
||||
}
|
||||
|
||||
void _syncEmphasis() {
|
||||
if (_hovered || _dragging) {
|
||||
_emphasis.forward();
|
||||
@@ -233,12 +311,13 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
final t = _emphasisCurve.value;
|
||||
final onSurface = _colorScheme.onSurface;
|
||||
final isDark = _colorScheme.brightness == Brightness.dark;
|
||||
final color = Color.lerp(
|
||||
onSurface.withValues(alpha: isDark ? 0.35 : 0.25),
|
||||
onSurface.withValues(alpha: isDark ? 0.65 : 0.5),
|
||||
t,
|
||||
)!;
|
||||
scrollbarPainter
|
||||
..color = Color.lerp(
|
||||
onSurface.withValues(alpha: isDark ? 0.35 : 0.25),
|
||||
onSurface.withValues(alpha: isDark ? 0.65 : 0.5),
|
||||
t,
|
||||
)!
|
||||
..color = color.withValues(alpha: color.a * _presence.value)
|
||||
..thickness = _restThickness + (_activeThickness - _restThickness) * t;
|
||||
}
|
||||
|
||||
@@ -248,6 +327,7 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
if (getScrollbarDirection() == null) {
|
||||
return;
|
||||
}
|
||||
_beginScrub();
|
||||
_dragging = true;
|
||||
_scrubOrigin = localPosition;
|
||||
_scrubLast = localPosition;
|
||||
@@ -312,7 +392,9 @@ class _AppScrollbarState extends RawScrollbarState<_AppScrollbar> {
|
||||
_syncEmphasis();
|
||||
// A fling would throw away the precision the slowed scrub just bought.
|
||||
super.handleThumbPressEnd(position, slowed ? Velocity.zero : velocity);
|
||||
_endScrub();
|
||||
widget.onScrub?.call(null);
|
||||
_syncSheet();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -484,7 +566,9 @@ class _FloatingScrollbarState extends State<FloatingScrollbar> {
|
||||
top = _thumbCenter(
|
||||
metrics,
|
||||
constraints.maxHeight,
|
||||
MediaQuery.paddingOf(context) + barPadding,
|
||||
MediaQuery.paddingOf(context) +
|
||||
barPadding +
|
||||
EdgeInsets.only(top: _barClearance(context)),
|
||||
).clamp(half, max(half, limit - half));
|
||||
label = widget.hintBuilder(fraction);
|
||||
}
|
||||
@@ -616,9 +700,10 @@ class _MeasureSizeRenderObject extends RenderProxyBox {
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollToEndBox<T> extends StatefulWidget {
|
||||
/// Tells a list following its end when the reader scrolls off the end and
|
||||
/// back onto it; the list's [FollowEndScrollPhysics] keeps it there.
|
||||
class ScrollToEndBox extends StatefulWidget {
|
||||
final ScrollController controller;
|
||||
final List<T> dataSource;
|
||||
final Widget child;
|
||||
final bool enable;
|
||||
final VoidCallback? onCancelToEnd;
|
||||
@@ -628,180 +713,59 @@ class ScrollToEndBox<T> extends StatefulWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.dataSource,
|
||||
this.onCancelToEnd,
|
||||
this.onResumeToEnd,
|
||||
this.enable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScrollToEndBox<T>> createState() => _ScrollToEndBoxState<T>();
|
||||
State<ScrollToEndBox> createState() => _ScrollToEndBoxState();
|
||||
}
|
||||
|
||||
class _ScrollToEndBoxState<T> extends State<ScrollToEndBox<T>> {
|
||||
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);
|
||||
|
||||
class _ScrollToEndBoxState extends State<ScrollToEndBox> {
|
||||
@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() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_scrollToEnd());
|
||||
});
|
||||
}
|
||||
|
||||
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.enable || !widget.controller.hasClients) {
|
||||
return;
|
||||
}
|
||||
if (_followPaused) {
|
||||
_followDeferred = true;
|
||||
return;
|
||||
}
|
||||
final position = widget.controller.position;
|
||||
if (_isAtEnd(position)) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
bool _dataSourceChanged(List<T> oldData, List<T> newData) {
|
||||
if (identical(oldData, newData)) {
|
||||
return false;
|
||||
}
|
||||
if (oldData.length != newData.length) {
|
||||
return true;
|
||||
}
|
||||
return oldData.isNotEmpty && oldData.last != newData.last;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ScrollToEndBox<T> oldWidget) {
|
||||
void didUpdateWidget(ScrollToEndBox oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!widget.enable) {
|
||||
return;
|
||||
}
|
||||
if (!oldWidget.enable ||
|
||||
_dataSourceChanged(oldWidget.dataSource, widget.dataSource)) {
|
||||
_scheduleScrollToEnd();
|
||||
if (widget.enable && !oldWidget.enable) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToEnd());
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleMetricsNotification(ScrollMetricsNotification notification) {
|
||||
if (notification.depth != 0) {
|
||||
return false;
|
||||
void _jumpToEnd() {
|
||||
final controller = widget.controller;
|
||||
if (!mounted || !widget.enable || !controller.hasClients) {
|
||||
return;
|
||||
}
|
||||
final viewportDimension = notification.metrics.viewportDimension;
|
||||
final resized =
|
||||
_viewportDimension != null && _viewportDimension != viewportDimension;
|
||||
_viewportDimension = viewportDimension;
|
||||
if (resized && widget.enable && !_isAtEnd(notification.metrics)) {
|
||||
_scheduleJumpToEnd();
|
||||
final position = controller.position;
|
||||
if (!FollowEndScrollPhysics.isAtEnd(position)) {
|
||||
controller.jumpTo(position.maxScrollExtent);
|
||||
}
|
||||
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();
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
final atEnd = FollowEndScrollPhysics.isAtEnd(notification.metrics);
|
||||
if (widget.enable && !atEnd) {
|
||||
widget.onCancelToEnd?.call();
|
||||
} else if (!widget.enable && atEnd) {
|
||||
widget.onResumeToEnd?.call();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: _handleMetricsNotification,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: widget.child,
|
||||
),
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-60
@@ -12,38 +12,34 @@ import 'snap_sheet.dart';
|
||||
|
||||
@immutable
|
||||
class SheetProps {
|
||||
final double? maxWidth;
|
||||
final double? maxHeight;
|
||||
final bool isScrollControlled;
|
||||
final bool useSafeArea;
|
||||
final Color? backgroundColor;
|
||||
|
||||
final List<double> detents;
|
||||
|
||||
final SheetTransition transition;
|
||||
|
||||
const SheetProps({
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
this.backgroundColor,
|
||||
this.useSafeArea = true,
|
||||
this.isScrollControlled = false,
|
||||
this.detents = snapSheetDetents,
|
||||
this.transition = SheetTransition.slide,
|
||||
});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ExtendProps {
|
||||
final double? maxWidth;
|
||||
final bool useSafeArea;
|
||||
final bool forceFull;
|
||||
|
||||
const ExtendProps({
|
||||
this.maxWidth,
|
||||
this.useSafeArea = true,
|
||||
this.forceFull = false,
|
||||
});
|
||||
const ExtendProps({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;
|
||||
enum SheetTransition { slide, stack }
|
||||
|
||||
const stackedSheetPeek = 14.0;
|
||||
|
||||
/// Flutter's Cupertino sheet measured this on iOS 18.
|
||||
const stackedSheetScale = 1 - 0.0835;
|
||||
|
||||
Future<T?> showSheet<T>({
|
||||
required BuildContext context,
|
||||
@@ -57,7 +53,9 @@ Future<T?> showSheet<T>({
|
||||
return navigator.push(
|
||||
SnapSheetRoute<T>(
|
||||
builder: (sheetContext, _) => builder(sheetContext),
|
||||
fitMaxHeight: props.isScrollControlled ? 1 : shortSheetMaxHeight,
|
||||
detents: props.detents,
|
||||
transition: props.transition,
|
||||
fitsContent: true,
|
||||
sheetBarrierColor: barrierColor,
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
@@ -69,13 +67,22 @@ Future<T?> showSheet<T>({
|
||||
),
|
||||
);
|
||||
}
|
||||
return showModalSideSheet<T>(
|
||||
useSafeArea: props.useSafeArea,
|
||||
isScrollControlled: props.isScrollControlled,
|
||||
context: context,
|
||||
return _showSideSheet<T>(
|
||||
context,
|
||||
backgroundColor: props.backgroundColor,
|
||||
constraints: BoxConstraints(maxWidth: props.maxWidth ?? 360),
|
||||
barrierColor: barrierColor,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> _showSideSheet<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return showModalSideSheet<T>(
|
||||
context: context,
|
||||
backgroundColor: backgroundColor,
|
||||
barrierColor: context.colorScheme.modalScrim,
|
||||
builder: (sheetContext) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
@@ -96,25 +103,14 @@ Future<T?> showExtend<T>(
|
||||
context,
|
||||
SheetProvider(type: SheetType.page, child: builder(context)),
|
||||
),
|
||||
false => showModalSideSheet<T>(
|
||||
useSafeArea: props.useSafeArea,
|
||||
context: context,
|
||||
constraints: BoxConstraints(maxWidth: props.maxWidth ?? 360),
|
||||
barrierColor: context.colorScheme.modalScrim,
|
||||
builder: (context) {
|
||||
return SheetProvider(
|
||||
type: SheetType.sideSheet,
|
||||
child: builder(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
false => _showSideSheet<T>(context, builder: builder),
|
||||
};
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// content keeps its own scroll controller and [initialScrollOffset] is the
|
||||
/// caller's own business.
|
||||
///
|
||||
/// The sheet follows the view across the mobile breakpoint: it reopens in the
|
||||
/// other form, and the result is whichever form the reader closes.
|
||||
@@ -123,8 +119,7 @@ Future<T?> showSnapSheet<T>(
|
||||
required SnapSheetBuilder builder,
|
||||
double initialScrollOffset = 0,
|
||||
List<double> detents = snapSheetDetents,
|
||||
double? collapsedDetent,
|
||||
SnapSheetController? controller,
|
||||
SheetTransition transition = SheetTransition.slide,
|
||||
}) {
|
||||
final completer = Completer<T?>();
|
||||
|
||||
@@ -138,9 +133,6 @@ Future<T?> showSnapSheet<T>(
|
||||
return;
|
||||
}
|
||||
crossed = true;
|
||||
if (!isMobile) {
|
||||
controller?.detachSide();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
open(isMobile: context.isMobileView);
|
||||
@@ -177,9 +169,8 @@ Future<T?> showSnapSheet<T>(
|
||||
SnapSheetRoute<T>(
|
||||
builder: home,
|
||||
detents: detents,
|
||||
collapsedDetent: collapsedDetent,
|
||||
transition: transition,
|
||||
initialScrollOffset: initialScrollOffset,
|
||||
sheetController: controller,
|
||||
sheetBarrierColor: barrierColor,
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
@@ -191,26 +182,14 @@ Future<T?> showSnapSheet<T>(
|
||||
),
|
||||
);
|
||||
} 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),
|
||||
);
|
||||
},
|
||||
closed = _showSideSheet<T>(
|
||||
context,
|
||||
builder: (sheetContext) => home(sheetContext, null),
|
||||
);
|
||||
}
|
||||
unawaited(
|
||||
closed.then((value) {
|
||||
popped = true;
|
||||
if (!isMobile) {
|
||||
controller?.detachSide();
|
||||
}
|
||||
if (!crossed) {
|
||||
completer.complete(value);
|
||||
}
|
||||
|
||||
@@ -140,9 +140,7 @@ class FloatingHeaderBody extends StatelessWidget {
|
||||
return Stack(
|
||||
children: [
|
||||
ScrollConfiguration(
|
||||
behavior: const ShowBarScrollBehavior(
|
||||
scrollbarPadding: EdgeInsets.only(top: sheetAppBarHeight),
|
||||
),
|
||||
behavior: const ShowBarScrollBehavior(),
|
||||
child: body,
|
||||
),
|
||||
Positioned(
|
||||
|
||||
@@ -17,3 +17,49 @@ NavigatorState sheetNavigatorOf(BuildContext context) {
|
||||
}
|
||||
return navigator;
|
||||
}
|
||||
|
||||
final _covers = Expando<ProxyAnimation>();
|
||||
|
||||
ProxyAnimation _coverOf(Route<dynamic> route) =>
|
||||
_covers[route] ??= ProxyAnimation(kAlwaysDismissedAnimation);
|
||||
|
||||
/// Hands the route below the sheet's entrance, for it to step back behind.
|
||||
mixin SheetCoverRouteMixin<T> on TransitionRoute<T> {
|
||||
ProxyAnimation? _covered;
|
||||
|
||||
@override
|
||||
void didChangePrevious(Route<dynamic>? previousRoute) {
|
||||
super.didChangePrevious(previousRoute);
|
||||
_uncover();
|
||||
if (previousRoute != null) {
|
||||
_covered = _coverOf(previousRoute)..parent = animation;
|
||||
}
|
||||
}
|
||||
|
||||
void _uncover() {
|
||||
final covered = _covered;
|
||||
if (covered != null && covered.parent == animation) {
|
||||
covered.parent = kAlwaysDismissedAnimation;
|
||||
}
|
||||
_covered = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_uncover();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs to 1 as a sheet covers the page or sheet that [context] is on.
|
||||
Animation<double> sheetCoverOf(BuildContext context) {
|
||||
var route = ModalRoute.of(context);
|
||||
while (route != null) {
|
||||
final navigator = route.navigator;
|
||||
if (navigator == null || navigator.widget is! SheetPagesNavigator) {
|
||||
return _coverOf(route);
|
||||
}
|
||||
route = ModalRoute.of(navigator.context);
|
||||
}
|
||||
return kAlwaysDismissedAnimation;
|
||||
}
|
||||
|
||||
+33
-141
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/color.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/widgets/drag_back.dart';
|
||||
@@ -8,46 +10,28 @@ import 'package:flutter/rendering.dart';
|
||||
const Duration _bottomSheetEnterDuration = Duration(milliseconds: 300);
|
||||
const Duration _bottomSheetExitDuration = Duration(milliseconds: 200);
|
||||
const Curve _modalBottomSheetCurve = Easing.standardDecelerate;
|
||||
const double _defaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0;
|
||||
|
||||
const double _minWidth = 360;
|
||||
const double _maxWidth = 560;
|
||||
const double _widthFactor = 0.4;
|
||||
|
||||
double _widthFor(double viewWidth) {
|
||||
return (viewWidth * _widthFactor).clamp(_minWidth, _maxWidth);
|
||||
}
|
||||
|
||||
class SideSheet extends StatefulWidget {
|
||||
const SideSheet({
|
||||
super.key,
|
||||
this.animationController,
|
||||
this.enableDrag = true,
|
||||
this.showDragHandle,
|
||||
this.dragHandleColor,
|
||||
this.dragHandleSize,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.backgroundColor,
|
||||
this.shadowColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior,
|
||||
this.constraints,
|
||||
required this.onClosing,
|
||||
required this.builder,
|
||||
}) : assert(elevation == null || elevation >= 0.0);
|
||||
|
||||
final AnimationController? animationController;
|
||||
|
||||
final VoidCallback onClosing;
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
final bool enableDrag;
|
||||
|
||||
final bool? showDragHandle;
|
||||
|
||||
final Color? dragHandleColor;
|
||||
|
||||
final Size? dragHandleSize;
|
||||
|
||||
final BottomSheetDragStartHandler? onDragStart;
|
||||
|
||||
final BottomSheetDragEndHandler? onDragEnd;
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final Color? shadowColor;
|
||||
@@ -58,8 +42,6 @@ class SideSheet extends StatefulWidget {
|
||||
|
||||
final Clip? clipBehavior;
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
@override
|
||||
State<SideSheet> createState() => _SideSheetState();
|
||||
|
||||
@@ -85,13 +67,9 @@ class _SideSheetState extends State<SideSheet> {
|
||||
final double elevation = widget.elevation ?? 0;
|
||||
final ShapeBorder shape = widget.shape ?? AppShape.none;
|
||||
|
||||
final BoxConstraints constraints =
|
||||
widget.constraints ??
|
||||
const BoxConstraints(maxWidth: 320, minWidth: 320);
|
||||
|
||||
final Clip clipBehavior = widget.clipBehavior ?? Clip.none;
|
||||
|
||||
final Widget sideSheet = Material(
|
||||
return Material(
|
||||
key: _childKey,
|
||||
color: color,
|
||||
elevation: elevation,
|
||||
@@ -101,8 +79,6 @@ class _SideSheetState extends State<SideSheet> {
|
||||
clipBehavior: clipBehavior,
|
||||
child: widget.builder(context),
|
||||
);
|
||||
|
||||
return ConstrainedBox(constraints: constraints, child: sideSheet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,15 +88,13 @@ class _SideSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget {
|
||||
const _SideSheetLayoutWithSizeListener({
|
||||
required this.onChildSizeChanged,
|
||||
required this.animationValue,
|
||||
required this.isScrollControlled,
|
||||
required this.scrollControlDisabledMaxHeightRatio,
|
||||
required this.width,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final _SizeChangeCallback<Size> onChildSizeChanged;
|
||||
final double animationValue;
|
||||
final bool isScrollControlled;
|
||||
final double scrollControlDisabledMaxHeightRatio;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
_RenderSideSheetLayoutWithSizeListener createRenderObject(
|
||||
@@ -129,8 +103,7 @@ class _SideSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget {
|
||||
return _RenderSideSheetLayoutWithSizeListener(
|
||||
onChildSizeChanged: onChildSizeChanged,
|
||||
animationValue: animationValue,
|
||||
isScrollControlled: isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,9 +114,7 @@ class _SideSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget {
|
||||
) {
|
||||
renderObject.onChildSizeChanged = onChildSizeChanged;
|
||||
renderObject.animationValue = animationValue;
|
||||
renderObject.isScrollControlled = isScrollControlled;
|
||||
renderObject.scrollControlDisabledMaxHeightRatio =
|
||||
scrollControlDisabledMaxHeightRatio;
|
||||
renderObject.width = width;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,13 +123,10 @@ class _RenderSideSheetLayoutWithSizeListener extends RenderShiftedBox {
|
||||
RenderBox? child,
|
||||
required _SizeChangeCallback<Size> onChildSizeChanged,
|
||||
required double animationValue,
|
||||
required bool isScrollControlled,
|
||||
required double scrollControlDisabledMaxHeightRatio,
|
||||
required double width,
|
||||
}) : _onChildSizeChanged = onChildSizeChanged,
|
||||
_animationValue = animationValue,
|
||||
_isScrollControlled = isScrollControlled,
|
||||
_scrollControlDisabledMaxHeightRatio =
|
||||
scrollControlDisabledMaxHeightRatio,
|
||||
_width = width,
|
||||
super(child);
|
||||
|
||||
Size _lastSize = Size.zero;
|
||||
@@ -187,28 +155,15 @@ class _RenderSideSheetLayoutWithSizeListener extends RenderShiftedBox {
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
bool get isScrollControlled => _isScrollControlled;
|
||||
bool _isScrollControlled;
|
||||
double get width => _width;
|
||||
double _width;
|
||||
|
||||
set isScrollControlled(bool newValue) {
|
||||
if (_isScrollControlled == newValue) {
|
||||
set width(double newValue) {
|
||||
if (_width == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isScrollControlled = newValue;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
double get scrollControlDisabledMaxHeightRatio =>
|
||||
_scrollControlDisabledMaxHeightRatio;
|
||||
double _scrollControlDisabledMaxHeightRatio;
|
||||
|
||||
set scrollControlDisabledMaxHeightRatio(double newValue) {
|
||||
if (_scrollControlDisabledMaxHeightRatio == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollControlDisabledMaxHeightRatio = newValue;
|
||||
_width = newValue;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -266,7 +221,10 @@ class _RenderSideSheetLayoutWithSizeListener extends RenderShiftedBox {
|
||||
}
|
||||
|
||||
BoxConstraints _getConstraintsForChild(BoxConstraints constraints) {
|
||||
return BoxConstraints(maxHeight: constraints.maxHeight);
|
||||
return BoxConstraints.tightFor(
|
||||
width: math.min(width, constraints.maxWidth),
|
||||
height: constraints.maxHeight,
|
||||
);
|
||||
}
|
||||
|
||||
Offset _getPositionForChild(Size size, Size childSize) {
|
||||
@@ -310,24 +268,13 @@ class _ModalSideSheet<T> extends StatefulWidget {
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior,
|
||||
this.constraints,
|
||||
this.isScrollControlled = false,
|
||||
this.scrollControlDisabledMaxHeightRatio =
|
||||
_defaultScrollControlDisabledMaxHeightRatio,
|
||||
this.enableDrag = true,
|
||||
this.showDragHandle = false,
|
||||
});
|
||||
|
||||
final ModalSideSheetRoute<T> route;
|
||||
final bool isScrollControlled;
|
||||
final double scrollControlDisabledMaxHeightRatio;
|
||||
final Color? backgroundColor;
|
||||
final double? elevation;
|
||||
final ShapeBorder? shape;
|
||||
final Clip? clipBehavior;
|
||||
final BoxConstraints? constraints;
|
||||
final bool enableDrag;
|
||||
final bool showDragHandle;
|
||||
|
||||
@override
|
||||
_ModalSideSheetState<T> createState() => _ModalSideSheetState<T>();
|
||||
@@ -361,33 +308,23 @@ class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
||||
context,
|
||||
);
|
||||
final String routeLabel = _getRouteLabel(localizations);
|
||||
final width = _widthFor(MediaQuery.sizeOf(context).width);
|
||||
|
||||
final route = widget.route;
|
||||
return AnimatedBuilder(
|
||||
animation: route._presence,
|
||||
animation: route.animation!,
|
||||
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 curve = route.isDragBackActive ? Curves.linear : animationCurve;
|
||||
final double animationValue =
|
||||
curve.transform(route.animation!.value) *
|
||||
(1 - (route.aside?.value ?? 0));
|
||||
final double animationValue = curve.transform(route.animation!.value);
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
namesRoute: true,
|
||||
@@ -401,9 +338,7 @@ class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
||||
);
|
||||
},
|
||||
animationValue: animationValue,
|
||||
isScrollControlled: widget.isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio:
|
||||
widget.scrollControlDisabledMaxHeightRatio,
|
||||
width: width,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
@@ -413,7 +348,8 @@ class _ModalSideSheetState<T> extends State<_ModalSideSheet<T>> {
|
||||
}
|
||||
}
|
||||
|
||||
class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
class ModalSideSheetRoute<T> extends PopupRoute<T>
|
||||
with DragBackRouteMixin<T>, SheetCoverRouteMixin<T> {
|
||||
ModalSideSheetRoute({
|
||||
required this.builder,
|
||||
this.capturedThemes,
|
||||
@@ -423,34 +359,17 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior,
|
||||
this.constraints,
|
||||
this.modalBarrierColor,
|
||||
this.isDismissible = true,
|
||||
this.isScrollControlled = false,
|
||||
this.scrollControlDisabledMaxHeightRatio =
|
||||
_defaultScrollControlDisabledMaxHeightRatio,
|
||||
super.settings,
|
||||
this.transitionAnimationController,
|
||||
this.anchorPoint,
|
||||
this.useSafeArea = false,
|
||||
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;
|
||||
|
||||
final double scrollControlDisabledMaxHeightRatio;
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final double? elevation;
|
||||
@@ -459,8 +378,6 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
|
||||
final Clip? clipBehavior;
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
final Color? modalBarrierColor;
|
||||
|
||||
final bool isDismissible;
|
||||
@@ -469,8 +386,6 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
|
||||
final Offset? anchorPoint;
|
||||
|
||||
final bool useSafeArea;
|
||||
|
||||
final String? barrierOnTapHint;
|
||||
|
||||
final ValueNotifier<EdgeInsets> _clipDetailsNotifier =
|
||||
@@ -536,10 +451,6 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
elevation: elevation ?? 0,
|
||||
shape: shape,
|
||||
clipBehavior: clipBehavior,
|
||||
constraints: constraints,
|
||||
isScrollControlled: isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio:
|
||||
scrollControlDisabledMaxHeightRatio,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -554,7 +465,7 @@ class ModalSideSheetRoute<T> extends PopupRoute<T> with DragBackRouteMixin<T> {
|
||||
Widget buildModalBarrier() {
|
||||
if (barrierColor.a != 0 && !offstage) {
|
||||
assert(barrierColor != barrierColor.opacity0);
|
||||
final Animation<Color?> color = _presence.drive(
|
||||
final Animation<Color?> color = animation!.drive(
|
||||
ColorTween(
|
||||
begin: barrierColor.opacity0,
|
||||
end: barrierColor,
|
||||
@@ -588,18 +499,12 @@ Future<T?> showModalSideSheet<T>({
|
||||
double? elevation,
|
||||
ShapeBorder? shape,
|
||||
Clip? clipBehavior,
|
||||
BoxConstraints? constraints,
|
||||
Color? barrierColor,
|
||||
bool isScrollControlled = false,
|
||||
double scrollControlDisabledMaxHeightRatio =
|
||||
_defaultScrollControlDisabledMaxHeightRatio,
|
||||
bool useRootNavigator = false,
|
||||
bool isDismissible = true,
|
||||
bool useSafeArea = false,
|
||||
RouteSettings? routeSettings,
|
||||
AnimationController? transitionAnimationController,
|
||||
Offset? anchorPoint,
|
||||
Animation<double>? aside,
|
||||
}) {
|
||||
assert(debugCheckHasMediaQuery(context));
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
@@ -615,8 +520,6 @@ Future<T?> showModalSideSheet<T>({
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
isScrollControlled: isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
||||
barrierLabel: barrierLabel ?? localizations.scrimLabel,
|
||||
barrierOnTapHint: localizations.scrimOnTapHint(
|
||||
localizations.bottomSheetLabel,
|
||||
@@ -625,23 +528,12 @@ Future<T?> showModalSideSheet<T>({
|
||||
elevation: elevation,
|
||||
shape: shape,
|
||||
clipBehavior: clipBehavior,
|
||||
constraints: constraints,
|
||||
isDismissible: isDismissible,
|
||||
modalBarrierColor:
|
||||
barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor,
|
||||
settings: routeSettings,
|
||||
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);
|
||||
}
|
||||
|
||||
+383
-312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,625 @@
|
||||
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:fl_clash/models/common.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'focus.dart';
|
||||
|
||||
/// The sort mode a [CommonScaffold] keeps for its body, or a section keeps
|
||||
/// for its own rows.
|
||||
class SortModeScope extends InheritedWidget {
|
||||
const SortModeScope({super.key, required this.sorting, required super.child});
|
||||
|
||||
final bool sorting;
|
||||
|
||||
static SortModeScope? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<SortModeScope>();
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SortModeScope oldWidget) =>
|
||||
sorting != oldWidget.sorting;
|
||||
}
|
||||
|
||||
typedef SortableItemData = ({int index, bool sorting});
|
||||
|
||||
/// While the [SortModeScope] above sorts, [DecorationListItem] and
|
||||
/// [ContextMenuRegion] below give up their taps and menus, and the trailing
|
||||
/// turns into a [SortHandle].
|
||||
class SortableItem extends StatelessWidget {
|
||||
const SortableItem({super.key, required this.index, required this.child});
|
||||
|
||||
final int index;
|
||||
final Widget child;
|
||||
|
||||
static SortableItemData? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_SortableItemScope>()?.data;
|
||||
|
||||
static bool isSorting(BuildContext context) =>
|
||||
maybeOf(context)?.sorting ?? false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SortableItemScope(
|
||||
data: (
|
||||
index: index,
|
||||
sorting: SortModeScope.maybeOf(context)?.sorting ?? false,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SortableItemScope extends InheritedWidget {
|
||||
const _SortableItemScope({required this.data, required super.child});
|
||||
|
||||
final SortableItemData data;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_SortableItemScope oldWidget) =>
|
||||
data != oldWidget.data;
|
||||
}
|
||||
|
||||
/// Marks a row whose tap opens a page, sized like the handle that replaces it
|
||||
/// in sort mode so the two share a center.
|
||||
class DisclosureIndicator extends StatelessWidget {
|
||||
const DisclosureIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox.square(
|
||||
dimension: kMinInteractiveDimension,
|
||||
child: Center(child: GlyphIcon(AppGlyphs.chevronForward)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing button of a row whose tap does something else, held to the
|
||||
/// [DisclosureIndicator]'s box since a desktop's compact density would shrink
|
||||
/// a bare [IconButton] and pull its glyph off the column.
|
||||
class DetailButton extends StatelessWidget {
|
||||
const DetailButton({
|
||||
super.key,
|
||||
required this.glyph,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Glyph glyph;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: kMinInteractiveDimension,
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: GlyphIcon(glyph),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drags on press; with focus, a select key lifts the row and the up and down
|
||||
/// keys move it, so a remote or a keyboard can sort without a pointer.
|
||||
class SortHandle extends StatefulWidget {
|
||||
const SortHandle({
|
||||
super.key,
|
||||
required this.index,
|
||||
this.dimension = kMinInteractiveDimension,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final double dimension;
|
||||
|
||||
@override
|
||||
State<SortHandle> createState() => _SortHandleState();
|
||||
}
|
||||
|
||||
class _SortHandleState extends State<SortHandle> {
|
||||
var _focused = false;
|
||||
var _lifted = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SortHandle oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index == widget.index || !_focused) {
|
||||
return;
|
||||
}
|
||||
final policy = widget.index > oldWidget.index
|
||||
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
|
||||
: ScrollPositionAlignmentPolicy.keepVisibleAtStart;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignmentPolicy: policy,
|
||||
duration: context.motionDuration(kThemeAnimationDuration),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleFocusChange(bool focused) {
|
||||
setState(() {
|
||||
_focused = focused;
|
||||
_lifted = _lifted && focused;
|
||||
});
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
if (remoteSelectKeys.contains(key) || key == LogicalKeyboardKey.space) {
|
||||
if (event is KeyDownEvent) {
|
||||
setState(() {
|
||||
_lifted = !_lifted;
|
||||
});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (!_lifted || event is KeyUpEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
setState(() {
|
||||
_lifted = false;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final offset = switch (key) {
|
||||
LogicalKeyboardKey.arrowUp => -1,
|
||||
LogicalKeyboardKey.arrowDown => 1,
|
||||
LogicalKeyboardKey.arrowLeft || LogicalKeyboardKey.arrowRight => 0,
|
||||
_ => null,
|
||||
};
|
||||
if (offset == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final list = SliverReorderableList.maybeOf(context)?.widget;
|
||||
final target = widget.index + offset;
|
||||
if (offset != 0 && list != null && target >= 0 && target < list.itemCount) {
|
||||
list.onReorderItem?.call(widget.index, target);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final dimension = widget.dimension;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: context.appLocalizations.sort,
|
||||
child: Focus(
|
||||
onFocusChange: _handleFocusChange,
|
||||
onKeyEvent: _handleKey,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.grab,
|
||||
child: ReorderableDragStartListener(
|
||||
index: widget.index,
|
||||
child: ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: context.motionDuration(kThemeAnimationDuration),
|
||||
width: dimension - 8,
|
||||
height: dimension - 8,
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.circle,
|
||||
color: _lifted
|
||||
? colorScheme.secondaryContainer
|
||||
: _focused
|
||||
? colorScheme.onSurface.withValues(alpha: 0.12)
|
||||
: colorScheme.secondaryContainer.withValues(alpha: 0),
|
||||
),
|
||||
child: Center(
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.dragHandle,
|
||||
color: _lifted
|
||||
? colorScheme.onSecondaryContainer
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _enterDuration = Duration(milliseconds: 650);
|
||||
const _exitDuration = Duration(milliseconds: 420);
|
||||
const _handleTravel = 1.0;
|
||||
|
||||
final _handleSpring = SpringCurve(
|
||||
SpringDescription.withDurationAndBounce(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
bounce: 0.55,
|
||||
),
|
||||
seconds: 0.65,
|
||||
);
|
||||
|
||||
/// The handle springs in from the row's trailing edge, where the card clips
|
||||
/// it, while the trailing it replaces shrinks away and the title gives up the
|
||||
/// width; leaving, the handle slides back out before the trailing pops back.
|
||||
/// Only a change of width relayouts the row: the motion itself is painted,
|
||||
/// since a rebuild here would relayout the row from its [LayoutBuilder] and a
|
||||
/// fade would put it on a layer of its own.
|
||||
class SortableTrailing extends StatefulWidget {
|
||||
const SortableTrailing({super.key, required this.item, this.child});
|
||||
|
||||
final SortableItemData item;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<SortableTrailing> createState() => _SortableTrailingState();
|
||||
}
|
||||
|
||||
class _SortableTrailingState extends State<SortableTrailing>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
value: widget.item.sorting ? 1 : 0,
|
||||
)..addStatusListener(_handleStatus);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SortableTrailing oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.item.sorting == widget.item.sorting) {
|
||||
return;
|
||||
}
|
||||
final sorting = widget.item.sorting;
|
||||
_controller.animateTo(
|
||||
sorting ? 1 : 0,
|
||||
duration: context.motionDuration(
|
||||
sorting ? _enterDuration : _exitDuration,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleStatus(AnimationStatus status) {
|
||||
if (!status.isAnimating) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sorting = widget.item.sorting;
|
||||
return RepaintBoundary(
|
||||
child: _SortableSlot(
|
||||
animation: _controller,
|
||||
sorting: sorting,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
excluding: sorting,
|
||||
child: ExcludeFocus(
|
||||
excluding: sorting,
|
||||
child: widget.child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (sorting || _controller.isAnimating)
|
||||
SortHandle(index: widget.item.index),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SortableSlot extends MultiChildRenderObjectWidget {
|
||||
const _SortableSlot({
|
||||
required this.animation,
|
||||
required this.sorting,
|
||||
super.children,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final bool sorting;
|
||||
|
||||
@override
|
||||
_RenderSortableSlot createRenderObject(BuildContext context) =>
|
||||
_RenderSortableSlot(
|
||||
animation: animation,
|
||||
sorting: sorting,
|
||||
textDirection: Directionality.of(context),
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderSortableSlot renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..animation = animation
|
||||
..sorting = sorting
|
||||
..textDirection = Directionality.of(context);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotParentData extends ContainerBoxParentData<RenderBox> {}
|
||||
|
||||
class _RenderSortableSlot extends RenderBox
|
||||
with
|
||||
ContainerRenderObjectMixin<RenderBox, _SlotParentData>,
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _SlotParentData> {
|
||||
_RenderSortableSlot({
|
||||
required Animation<double> animation,
|
||||
required bool sorting,
|
||||
required TextDirection textDirection,
|
||||
}) : _animation = animation,
|
||||
_sorting = sorting,
|
||||
_textDirection = textDirection;
|
||||
|
||||
Animation<double> _animation;
|
||||
bool _sorting;
|
||||
TextDirection _textDirection;
|
||||
double? _laidOutWidthProgress;
|
||||
final _trailingLayer = LayerHandle<TransformLayer>();
|
||||
final _handleLayer = LayerHandle<TransformLayer>();
|
||||
|
||||
set animation(Animation<double> value) {
|
||||
if (identical(value, _animation)) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_animation.removeListener(_handleTick);
|
||||
value.addListener(_handleTick);
|
||||
}
|
||||
_animation = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
set sorting(bool value) {
|
||||
if (value == _sorting) {
|
||||
return;
|
||||
}
|
||||
_sorting = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
set textDirection(TextDirection value) {
|
||||
if (value == _textDirection) {
|
||||
return;
|
||||
}
|
||||
_textDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
RenderBox get _trailing => firstChild!;
|
||||
|
||||
RenderBox? get _handle => childAfter(firstChild!);
|
||||
|
||||
double get _widthProgress {
|
||||
final value = _animation.value;
|
||||
return _sorting
|
||||
? const Interval(0, 0.5, curve: Curves.easeOutCubic).transform(value)
|
||||
: 1 -
|
||||
const Interval(
|
||||
0.2,
|
||||
0.8,
|
||||
curve: Curves.easeInOutCubic,
|
||||
).transform(1 - value);
|
||||
}
|
||||
|
||||
double get _handleProgress {
|
||||
final value = _animation.value;
|
||||
return _sorting
|
||||
? _handleSpring.transform(value)
|
||||
: const Interval(0.55, 1, curve: Curves.easeOut).transform(value);
|
||||
}
|
||||
|
||||
double get _trailingScale {
|
||||
final value = _animation.value;
|
||||
return _sorting
|
||||
? 1 - const Interval(0, 0.2, curve: Curves.easeIn).transform(value)
|
||||
: const Interval(
|
||||
0.4,
|
||||
1,
|
||||
curve: Curves.easeOutBack,
|
||||
).transform(1 - value);
|
||||
}
|
||||
|
||||
void _handleTick() {
|
||||
final resizing =
|
||||
_laidOutWidthProgress != null &&
|
||||
_laidOutWidthProgress != _widthProgress;
|
||||
if (resizing) {
|
||||
markNeedsLayout();
|
||||
} else {
|
||||
markNeedsPaint();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! _SlotParentData) {
|
||||
child.parentData = _SlotParentData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_animation.addListener(_handleTick);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_animation.removeListener(_handleTick);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_trailingLayer.layer = null;
|
||||
_handleLayer.layer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _lerpWidth(double trailing, double? handle) =>
|
||||
handle == null ? trailing : lerpDouble(trailing, handle, _widthProgress)!;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => _lerpWidth(
|
||||
_trailing.getMinIntrinsicWidth(height),
|
||||
_handle?.getMinIntrinsicWidth(height),
|
||||
);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) => _lerpWidth(
|
||||
_trailing.getMaxIntrinsicWidth(height),
|
||||
_handle?.getMaxIntrinsicWidth(height),
|
||||
);
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => math.max(
|
||||
_trailing.getMinIntrinsicHeight(width),
|
||||
_handle?.getMinIntrinsicHeight(width) ?? 0,
|
||||
);
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => math.max(
|
||||
_trailing.getMaxIntrinsicHeight(width),
|
||||
_handle?.getMaxIntrinsicHeight(width) ?? 0,
|
||||
);
|
||||
|
||||
@override
|
||||
Size computeDryLayout(BoxConstraints constraints) {
|
||||
final loose = constraints.loosen();
|
||||
final trailing = _trailing.getDryLayout(loose);
|
||||
final handle = _handle?.getDryLayout(loose);
|
||||
return constraints.constrain(
|
||||
Size(
|
||||
_lerpWidth(trailing.width, handle?.width),
|
||||
math.max(trailing.height, handle?.height ?? 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final loose = constraints.loosen();
|
||||
final trailing = _trailing..layout(loose, parentUsesSize: true);
|
||||
final handle = _handle?..layout(loose, parentUsesSize: true);
|
||||
final resizes = handle != null && handle.size.width != trailing.size.width;
|
||||
_laidOutWidthProgress = resizes ? _widthProgress : null;
|
||||
size = constraints.constrain(
|
||||
Size(
|
||||
_lerpWidth(trailing.size.width, handle?.size.width),
|
||||
math.max(trailing.size.height, handle?.size.height ?? 0),
|
||||
),
|
||||
);
|
||||
for (final child in [trailing, ?handle]) {
|
||||
(child.parentData! as _SlotParentData).offset = Offset(
|
||||
_textDirection == TextDirection.rtl ? 0 : size.width - child.size.width,
|
||||
(size.height - child.size.height) / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Matrix4 _transformOf(RenderBox child) {
|
||||
final offset = (child.parentData! as _SlotParentData).offset;
|
||||
if (child != _trailing) {
|
||||
final direction = _textDirection == TextDirection.rtl ? -1 : 1;
|
||||
return Matrix4.translationValues(
|
||||
offset.dx +
|
||||
direction *
|
||||
_handleTravel *
|
||||
(1 - _handleProgress) *
|
||||
child.size.width,
|
||||
offset.dy,
|
||||
0,
|
||||
);
|
||||
}
|
||||
final scale = _trailingScale;
|
||||
return Matrix4.diagonal3Values(scale, scale, 1)..setTranslationRaw(
|
||||
offset.dx + child.size.width / 2 * (1 - scale),
|
||||
offset.dy + child.size.height / 2 * (1 - scale),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
TransformLayer? _paintChild(
|
||||
PaintingContext context,
|
||||
Offset offset,
|
||||
RenderBox child,
|
||||
TransformLayer? oldLayer,
|
||||
) => context.pushTransform(
|
||||
needsCompositing,
|
||||
offset,
|
||||
_transformOf(child),
|
||||
(context, offset) => context.paintChild(child, offset),
|
||||
oldLayer: oldLayer,
|
||||
);
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
_trailingLayer.layer = _trailingScale <= 0
|
||||
? null
|
||||
: _paintChild(context, offset, _trailing, _trailingLayer.layer);
|
||||
final handle = _handle;
|
||||
_handleLayer.layer = handle == null
|
||||
? null
|
||||
: _paintChild(context, offset, handle, _handleLayer.layer);
|
||||
}
|
||||
|
||||
bool _hitTestChild(
|
||||
BoxHitTestResult result,
|
||||
Offset position,
|
||||
RenderBox child,
|
||||
) => result.addWithPaintTransform(
|
||||
transform: _transformOf(child),
|
||||
position: position,
|
||||
hitTest: (result, position) => child.hitTest(result, position: position),
|
||||
);
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
final handle = _handle;
|
||||
if (handle != null && _hitTestChild(result, position, handle)) {
|
||||
return true;
|
||||
}
|
||||
return !_sorting &&
|
||||
_trailingScale > 0 &&
|
||||
_hitTestChild(result, position, _trailing);
|
||||
}
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
||||
transform.multiply(_transformOf(child));
|
||||
}
|
||||
}
|
||||
|
||||
IconButtonData sortModeAction(
|
||||
BuildContext context, {
|
||||
required bool sorting,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return IconButtonData(
|
||||
glyph: AppGlyphs.sort,
|
||||
onPressed: onPressed,
|
||||
tooltip: appLocalizations.sort,
|
||||
isSelected: sorting,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import 'dart:math';
|
||||
|
||||
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';
|
||||
|
||||
enum SourceKind { remote, file, custom }
|
||||
|
||||
extension on SourceKind {
|
||||
Color tintOf(ColorScheme colorScheme) => switch (this) {
|
||||
SourceKind.remote => colorScheme.primary,
|
||||
SourceKind.file => colorScheme.tertiary,
|
||||
SourceKind.custom => colorScheme.secondary,
|
||||
};
|
||||
}
|
||||
|
||||
/// The tile sits [inset] from the edge of an [AppCorner.xl] surface, so its
|
||||
/// corner is concentric with the surface's.
|
||||
const _sourceIconInset = 12.0;
|
||||
const _sourceIconSize = 40.0;
|
||||
final _sourceIconShape = AppShape.all(AppCorner.xl - _sourceIconInset);
|
||||
|
||||
class SourceIcon extends StatelessWidget {
|
||||
const SourceIcon({
|
||||
super.key,
|
||||
required this.kind,
|
||||
this.usage,
|
||||
this.tooltip,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
static const size = _sourceIconSize;
|
||||
static const inset = _sourceIconInset;
|
||||
|
||||
final SourceKind kind;
|
||||
final double? usage;
|
||||
final String? tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
final usage = this.usage;
|
||||
final icon = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: ShapeDecoration(
|
||||
color: kind.tintOf(colorScheme).opacity15,
|
||||
shape: _sourceIconShape,
|
||||
),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: usage ?? 0),
|
||||
duration: commonDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, level, _) => CustomPaint(
|
||||
painter: _SourceArtPainter(
|
||||
kind: kind,
|
||||
colorScheme: colorScheme,
|
||||
level: usage == null ? null : level,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (onPressed == null) {
|
||||
return icon;
|
||||
}
|
||||
return Focus(
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _focusNextOnRight,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size.square(size),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: _sourceIconShape,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Directional focus treats the enclosing card as the nearest node to the
|
||||
// right, so right would return to the card instead of reaching edit.
|
||||
static KeyEventResult _focusNextOnRight(FocusNode _, KeyEvent event) {
|
||||
if (event is! KeyDownEvent ||
|
||||
event.logicalKey != LogicalKeyboardKey.arrowRight) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
return FocusManager.instance.primaryFocus?.nextFocus() == true
|
||||
? KeyEventResult.handled
|
||||
: KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceArtPainter extends CustomPainter {
|
||||
const _SourceArtPainter({
|
||||
required this.kind,
|
||||
required this.colorScheme,
|
||||
required this.level,
|
||||
});
|
||||
|
||||
static const _waveAmplitude = 1.2;
|
||||
static const _cloudBounds = Rect.fromLTRB(5, 12, 33, 31.5);
|
||||
static final _cloud = _cloudPath(_cloudBounds);
|
||||
static const _badgeCenter = Offset(30, 28.5);
|
||||
static final _badgedCloud = Path.combine(
|
||||
PathOperation.difference,
|
||||
_cloud,
|
||||
Path()..addOval(Rect.fromCircle(center: _badgeCenter, radius: 7.4)),
|
||||
);
|
||||
|
||||
final SourceKind kind;
|
||||
final ColorScheme colorScheme;
|
||||
final double? level;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.scale(size.width / _sourceIconSize);
|
||||
switch (kind) {
|
||||
case SourceKind.remote:
|
||||
_paintRemote(canvas);
|
||||
case SourceKind.file:
|
||||
_paintFile(canvas);
|
||||
case SourceKind.custom:
|
||||
_paintCustom(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
static Path _cloudPath(Rect bounds) {
|
||||
final width = bounds.width;
|
||||
final left = bounds.left;
|
||||
final bottom = bounds.bottom;
|
||||
final leftLobe = width * 0.25;
|
||||
final rightLobe = width * 0.21;
|
||||
final top = bounds.height - width * 0.33;
|
||||
return Path.combine(
|
||||
PathOperation.union,
|
||||
Path.combine(
|
||||
PathOperation.union,
|
||||
Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(left + leftLobe, bottom - leftLobe),
|
||||
radius: leftLobe,
|
||||
),
|
||||
)
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(left + width - rightLobe, bottom - rightLobe),
|
||||
radius: rightLobe,
|
||||
),
|
||||
),
|
||||
Path()..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(left + width * 0.53, bounds.top + width * 0.33),
|
||||
radius: width * 0.33,
|
||||
),
|
||||
),
|
||||
),
|
||||
Path()..addRect(
|
||||
Rect.fromLTRB(
|
||||
left + leftLobe,
|
||||
bottom - top,
|
||||
left + width - rightLobe,
|
||||
bottom,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _paintRemote(Canvas canvas) {
|
||||
final primary = colorScheme.primary;
|
||||
final tertiary = colorScheme.tertiary;
|
||||
_paintGlyph(canvas, AppGlyphs.sparkle, const Offset(31, 9.5), 11, tertiary);
|
||||
final level = this.level;
|
||||
// A solid cloud is what a gauge with nothing used looks like.
|
||||
if (level == null) {
|
||||
canvas.drawPath(_badgedCloud, Paint()..color = primary);
|
||||
_paintGlyph(canvas, AppGlyphs.removeCircle, _badgeCenter, 14, tertiary);
|
||||
return;
|
||||
}
|
||||
canvas
|
||||
..drawPath(_cloud, Paint()..color = primary.withValues(alpha: 0.32))
|
||||
..save()
|
||||
..clipPath(_cloud);
|
||||
_paintWater(canvas, _cloudBounds, 1 - level, primary);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintFile(Canvas canvas) {
|
||||
final tertiary = colorScheme.tertiary;
|
||||
final ink = Color.lerp(tertiary, colorScheme.onTertiary, 0.5)!;
|
||||
canvas
|
||||
..save()
|
||||
..translate(16.5, 20.5)
|
||||
..rotate(-pi / 24)
|
||||
..drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(
|
||||
Rect.fromCenter(center: Offset.zero, width: 14, height: 20.5),
|
||||
const Radius.circular(3.5),
|
||||
),
|
||||
Paint()..color = colorScheme.secondary.withValues(alpha: 0.38),
|
||||
)
|
||||
..restore();
|
||||
const page = Rect.fromLTRB(15, 8.5, 30, 31);
|
||||
const fold = 6.0;
|
||||
final corner = Path()
|
||||
..moveTo(page.right - fold, page.top)
|
||||
..lineTo(page.right, page.top)
|
||||
..lineTo(page.right, page.top + fold)
|
||||
..close();
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(
|
||||
Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(page)
|
||||
..addPath(corner, Offset.zero),
|
||||
)
|
||||
..drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(page, const Radius.circular(3.5)),
|
||||
Paint()..color = tertiary,
|
||||
)
|
||||
..restore()
|
||||
..drawPath(
|
||||
Path()
|
||||
..moveTo(page.right - fold, page.top)
|
||||
..lineTo(page.right - fold, page.top + fold - 2)
|
||||
..quadraticBezierTo(
|
||||
page.right - fold,
|
||||
page.top + fold,
|
||||
page.right - fold + 2,
|
||||
page.top + fold,
|
||||
)
|
||||
..lineTo(page.right, page.top + fold)
|
||||
..close(),
|
||||
Paint()..color = ink,
|
||||
);
|
||||
for (final (top, right) in const [(19.5, 26.5), (24.0, 23.5)]) {
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromLTRBR(
|
||||
18.5,
|
||||
top,
|
||||
right,
|
||||
top + 2.2,
|
||||
const Radius.circular(1.1),
|
||||
),
|
||||
Paint()..color = ink,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintCustom(Canvas canvas) {
|
||||
const block = 11.5;
|
||||
const gap = 3.0;
|
||||
const start = (_sourceIconSize - block * 2 - gap) / 2;
|
||||
const end = start + block + gap;
|
||||
for (final (left, top, color) in [
|
||||
(start, start, colorScheme.primary),
|
||||
(start, end, colorScheme.tertiary),
|
||||
(end, end, colorScheme.secondary),
|
||||
]) {
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromLTRBR(
|
||||
left,
|
||||
top,
|
||||
left + block,
|
||||
top + block,
|
||||
const Radius.circular(3.5),
|
||||
),
|
||||
Paint()..color = color,
|
||||
);
|
||||
}
|
||||
final plus = Paint()
|
||||
..color = colorScheme.primary
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round;
|
||||
const center = Offset(end + block / 2, start + block / 2);
|
||||
const arm = block / 2 - 1.5;
|
||||
canvas
|
||||
..drawLine(center.translate(-arm, 0), center.translate(arm, 0), plus)
|
||||
..drawLine(center.translate(0, -arm), center.translate(0, arm), plus);
|
||||
}
|
||||
|
||||
void _paintGlyph(
|
||||
Canvas canvas,
|
||||
Glyph glyph,
|
||||
Offset center,
|
||||
double extent,
|
||||
Color color,
|
||||
) {
|
||||
canvas
|
||||
..save()
|
||||
..translate(center.dx - extent / 2, center.dy - extent / 2);
|
||||
GlyphPainter(
|
||||
glyph: glyph,
|
||||
fill: 1,
|
||||
color: color,
|
||||
).paint(canvas, Size.square(extent));
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintWater(Canvas canvas, Rect bounds, double level, Color color) {
|
||||
final surface = bounds.bottom - bounds.height * level;
|
||||
final amplitude = _waveAmplitude * sin(pi * level);
|
||||
final quarter = bounds.width / 4;
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(bounds.left, surface)
|
||||
..quadraticBezierTo(
|
||||
bounds.left + quarter,
|
||||
surface - amplitude * 2,
|
||||
bounds.left + quarter * 2,
|
||||
surface,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
bounds.left + quarter * 3,
|
||||
surface + amplitude * 2,
|
||||
bounds.right,
|
||||
surface,
|
||||
)
|
||||
..lineTo(bounds.right, bounds.bottom)
|
||||
..lineTo(bounds.left, bounds.bottom)
|
||||
..close(),
|
||||
Paint()..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SourceArtPainter oldDelegate) =>
|
||||
oldDelegate.kind != kind ||
|
||||
oldDelegate.colorScheme != colorScheme ||
|
||||
oldDelegate.level != level;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:math';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/activate_box.dart';
|
||||
import 'package:fl_clash/widgets/button.dart';
|
||||
import 'package:fl_clash/widgets/defer_pointer.dart';
|
||||
import 'package:fl_clash/widgets/motion_grid.dart';
|
||||
import 'package:fl_clash/widgets/navigation_dock.dart';
|
||||
@@ -590,16 +591,10 @@ class _DeletableContainer extends StatelessWidget {
|
||||
right: -8,
|
||||
child: DeferPointer(
|
||||
child: ElasticButton(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IconButton.filled(
|
||||
tooltip: context.appLocalizations.remove,
|
||||
iconSize: 16,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onDelete,
|
||||
icon: const GlyphIcon(AppGlyphs.close, fill: 1),
|
||||
),
|
||||
child: CornerBadgeButton(
|
||||
glyph: AppGlyphs.removeCircle,
|
||||
tooltip: context.appLocalizations.remove,
|
||||
onPressed: onDelete,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
export 'activate_box.dart';
|
||||
export 'motion_grid.dart';
|
||||
export 'active_polling.dart';
|
||||
export 'animated_entries.dart';
|
||||
export 'animated_visibility.dart';
|
||||
export 'builder.dart';
|
||||
export 'button.dart';
|
||||
export 'card.dart';
|
||||
export 'caret_popup.dart';
|
||||
export 'chip.dart';
|
||||
export 'collapsible_section.dart';
|
||||
export 'color_scheme_box.dart';
|
||||
export 'defer_pointer.dart';
|
||||
export 'config_item.dart';
|
||||
export 'context_menu.dart';
|
||||
export 'deferred_push.dart';
|
||||
export 'dialog.dart';
|
||||
export 'disabled_mask.dart';
|
||||
export 'dismissible.dart';
|
||||
export 'donut_chart.dart';
|
||||
export 'drag_back.dart';
|
||||
export 'effect.dart';
|
||||
@@ -29,6 +32,7 @@ export 'line_chart.dart';
|
||||
export 'list.dart';
|
||||
export 'loading.dart';
|
||||
export 'navigation_dock.dart';
|
||||
export 'navigator_resizable.dart';
|
||||
export 'notification.dart';
|
||||
export 'null_status.dart';
|
||||
export 'open_container.dart';
|
||||
@@ -49,6 +53,8 @@ export 'side_sheet.dart';
|
||||
export 'sidebar.dart';
|
||||
export 'snap_sheet.dart';
|
||||
export 'skeleton.dart';
|
||||
export 'sortable.dart';
|
||||
export 'source_icon.dart';
|
||||
export 'subscription_info_view.dart';
|
||||
export 'super_grid.dart';
|
||||
export 'super_reorderable_list.dart';
|
||||
|
||||
Reference in New Issue
Block a user