feat(ui): stack bottom sheets and add context menus

Changelog: Stacked sheets, context menus and a sort mode
This commit is contained in:
chen08209
2026-10-08 17:58:43 +08:00
parent 532ebf07e9
commit 8c43053951
108 changed files with 13877 additions and 3822 deletions
+16
View File
@@ -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('_');
+20
View File
@@ -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
View File
@@ -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,
);
},
),
),
);
+1 -11
View File
@@ -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
View File
@@ -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);
}
+32
View File
@@ -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
View File
@@ -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>(
+53
View File
@@ -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].
+10
View File
@@ -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
View File
@@ -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),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1127 -373
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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
View File
@@ -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);
+20 -1
View File
@@ -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;
+249
View File
@@ -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),
],
);
}
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+194
View File
@@ -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),
],
),
),
),
],
);
}
}
+4 -4
View File
@@ -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,
+165
View File
@@ -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,
),
),
);
}
}
+46
View File
@@ -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);
}
}
-137
View File
@@ -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,
);
}
}
+31 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+431
View File
@@ -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();
}
}
}
+71 -41
View File
@@ -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,
);
}
}
+38 -1
View File
@@ -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;
+4 -2
View File
@@ -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;
+523
View File
@@ -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));
}
}
}
+98 -41
View File
@@ -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
View File
@@ -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);
},
);
}
}
+302
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+1 -3
View File
@@ -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(
+46
View File
@@ -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
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+625
View File
@@ -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,
);
}
+339
View File
@@ -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;
}
+5 -10
View File
@@ -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,
),
),
),
+7 -1
View File
@@ -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';