feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
This commit is contained in:
+11
-8
@@ -156,14 +156,16 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
builder: (context, child) {
|
||||
return buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
terminateSignals: _terminateSignals,
|
||||
child: RemoteFocusAdapter(
|
||||
enabled: system.isTV,
|
||||
floatingKeyboard: app?.floatingKeyboard,
|
||||
child: child!,
|
||||
return ThemeCrossFade(
|
||||
child: buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
terminateSignals: _terminateSignals,
|
||||
child: RemoteFocusAdapter(
|
||||
enabled: system.isTV,
|
||||
floatingKeyboard: app?.floatingKeyboard,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -174,6 +176,7 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
themeMode: themeProps.themeMode,
|
||||
theme: watchAppTheme(ref, Brightness.light),
|
||||
darkTheme: watchAppTheme(ref, Brightness.dark),
|
||||
themeAnimationStyle: AnimationStyle.noAnimation,
|
||||
home: KeyboardInsetHold(child: child!),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -150,7 +150,7 @@ const proxiesListStoreKey = PageStorageKey<String>('proxies_list');
|
||||
const toolsStoreKey = PageStorageKey<String>('tools');
|
||||
const profilesStoreKey = PageStorageKey<String>('profiles');
|
||||
|
||||
const defaultPrimaryColor = 0XFFD8C0C3;
|
||||
const defaultPrimaryColor = 0xFF007AFF;
|
||||
|
||||
const maxLogsLength = 5000;
|
||||
const maxRequestsLength = 2000;
|
||||
@@ -161,14 +161,16 @@ const pausedMaxDnsQueriesLength = maxDnsQueriesLength * 2;
|
||||
|
||||
const trafficSampleLength = 30;
|
||||
|
||||
// Apple's system colors, in the order of the macOS accent colors.
|
||||
const defaultPrimaryColors = [
|
||||
0xFF795548,
|
||||
0xFF03A9F4,
|
||||
0xFFFFFF00,
|
||||
0XFFBBC9CC,
|
||||
0XFFABD397,
|
||||
defaultPrimaryColor,
|
||||
0XFF665390,
|
||||
0xFFAF52DE,
|
||||
0xFFFF2D55,
|
||||
0xFFFF3B30,
|
||||
0xFFFF9500,
|
||||
0xFFFFCC00,
|
||||
0xFF34C759,
|
||||
0xFF8E8E93,
|
||||
];
|
||||
|
||||
const scriptTemplate = '''
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/database/database.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
|
||||
import 'preferences.dart';
|
||||
import 'task.dart';
|
||||
@@ -25,6 +28,10 @@ abstract interface class MigrationStore {
|
||||
Future<void> clearClashConfig();
|
||||
|
||||
Future<void> setVersion(int version);
|
||||
|
||||
Future<int> getUpgradeVersion();
|
||||
|
||||
Future<void> setUpgradeVersion(int version);
|
||||
}
|
||||
|
||||
class _AppMigrationStore implements MigrationStore {
|
||||
@@ -63,6 +70,13 @@ class _AppMigrationStore implements MigrationStore {
|
||||
|
||||
@override
|
||||
Future<void> setVersion(int version) => preferences.setVersion(version);
|
||||
|
||||
@override
|
||||
Future<int> getUpgradeVersion() => preferences.getUpgradeVersion();
|
||||
|
||||
@override
|
||||
Future<void> setUpgradeVersion(int version) =>
|
||||
preferences.setUpgradeVersion(version);
|
||||
}
|
||||
|
||||
class Migration {
|
||||
@@ -75,8 +89,40 @@ class Migration {
|
||||
|
||||
static const currentVersion = 1;
|
||||
|
||||
/// One-off setting changes for upgrades, apart from [currentVersion], which
|
||||
/// backups carry and an older app refuses when it is newer than its own.
|
||||
static const upgradeVersion = 2;
|
||||
|
||||
static const Map<int, Config Function(Config config)> _upgrades = {
|
||||
1: _resetThemeColors,
|
||||
2: _keepGlassLook,
|
||||
};
|
||||
|
||||
Future<Config> run() async {
|
||||
final configMap = await _store.getConfigMap();
|
||||
final config = await _migrate(configMap);
|
||||
return _upgrade(config, installed: configMap != null);
|
||||
}
|
||||
|
||||
Future<Config> _upgrade(Config config, {required bool installed}) async {
|
||||
final from = await _store.getUpgradeVersion();
|
||||
if (from >= upgradeVersion) {
|
||||
return config;
|
||||
}
|
||||
var upgraded = config;
|
||||
if (installed) {
|
||||
for (var version = from + 1; version <= upgradeVersion; version++) {
|
||||
upgraded = _upgrades[version]!(upgraded);
|
||||
}
|
||||
}
|
||||
if (upgraded != config && !await _store.saveConfig(upgraded)) {
|
||||
return config;
|
||||
}
|
||||
await _store.setUpgradeVersion(upgradeVersion);
|
||||
return upgraded;
|
||||
}
|
||||
|
||||
Future<Config> _migrate(Map<String, Object?>? configMap) async {
|
||||
var oldVersion = await _store.getVersion();
|
||||
Config? config;
|
||||
if (oldVersion > currentVersion) {
|
||||
@@ -145,6 +191,24 @@ class Migration {
|
||||
}
|
||||
}
|
||||
|
||||
Config _resetThemeColors(Config config) => config.copyWith(
|
||||
themeProps: config.themeProps.copyWith(
|
||||
primaryColor: defaultPrimaryColor,
|
||||
primaryColors: defaultPrimaryColors,
|
||||
colorStyle: ColorStyle.vivid,
|
||||
),
|
||||
);
|
||||
|
||||
// The standard glass became the ultra tier when a lighter standard joined it.
|
||||
Config _keepGlassLook(Config config) =>
|
||||
config.themeProps.liquidGlass == LiquidGlassMode.standard
|
||||
? config.copyWith(
|
||||
themeProps: config.themeProps.copyWith(
|
||||
liquidGlass: LiquidGlassMode.ultra,
|
||||
),
|
||||
)
|
||||
: config;
|
||||
|
||||
bool _isV0(Map<String, Object?>? configMap) =>
|
||||
configMap?['proxiesStyle'] != null;
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ class Preferences {
|
||||
await preferences?.setInt('version', version);
|
||||
}
|
||||
|
||||
Future<int> getUpgradeVersion() async {
|
||||
final preferences = await _store.value;
|
||||
return preferences?.getInt('upgradeVersion') ?? 0;
|
||||
}
|
||||
|
||||
Future<void> setUpgradeVersion(int version) async {
|
||||
final preferences = await _store.value;
|
||||
await preferences?.setInt('upgradeVersion', version);
|
||||
}
|
||||
|
||||
Future<void> saveShareState(SharedState shareState) async {
|
||||
final preferences = await _store.value;
|
||||
await preferences?.setString('sharedState', json.encode(shareState));
|
||||
|
||||
@@ -35,6 +35,9 @@ Widget _backButtonIcon(BuildContext context) =>
|
||||
Widget _closeButtonIcon(BuildContext context) =>
|
||||
const GlyphIcon(AppGlyphs.close);
|
||||
|
||||
/// A rebuilt ThemeData never equals the last, and ThemeCrossFade fades on that.
|
||||
final _appThemes = <Brightness, ({Object inputs, ThemeData theme})>{};
|
||||
|
||||
ThemeData watchAppTheme(WidgetRef ref, Brightness brightness) {
|
||||
final locale = ref.watch(appSettingProvider.select((state) => state.locale));
|
||||
final themeSetting = ref.watch(
|
||||
@@ -43,7 +46,27 @@ ThemeData watchAppTheme(WidgetRef ref, Brightness brightness) {
|
||||
),
|
||||
);
|
||||
final colorScheme = ref.watch(genColorSchemeProvider(brightness));
|
||||
return ThemeData(
|
||||
final plainDark = brightness == Brightness.dark && themeSetting.pureBlack
|
||||
? ref.watch(genColorSchemeProvider(brightness, pureBlack: false))
|
||||
: null;
|
||||
final locales = [
|
||||
?getLocaleForString(locale),
|
||||
...WidgetsBinding.instance.platformDispatcher.locales,
|
||||
];
|
||||
final inputs = (
|
||||
platform: defaultTargetPlatform,
|
||||
themeSetting: themeSetting,
|
||||
colorScheme: colorScheme,
|
||||
plainDark: plainDark,
|
||||
locales: locales.join(','),
|
||||
);
|
||||
if (_appThemes[brightness] case (
|
||||
inputs: final cached,
|
||||
:final theme,
|
||||
) when cached == inputs) {
|
||||
return theme;
|
||||
}
|
||||
final theme = ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
actionIconTheme: _actionIconTheme,
|
||||
@@ -51,10 +74,7 @@ ThemeData watchAppTheme(WidgetRef ref, Brightness brightness) {
|
||||
fontFamily: themeSetting.fontFamily,
|
||||
fontFamilyFallback: appFontFamilyFallback(
|
||||
platform: defaultTargetPlatform,
|
||||
locales: [
|
||||
?getLocaleForString(locale),
|
||||
...WidgetsBinding.instance.platformDispatcher.locales,
|
||||
],
|
||||
locales: locales,
|
||||
fontFamily: themeSetting.fontFamily,
|
||||
),
|
||||
extensions: [
|
||||
@@ -62,12 +82,12 @@ ThemeData watchAppTheme(WidgetRef ref, Brightness brightness) {
|
||||
ElevatedColorScheme(
|
||||
// Plain dark is pure black a level up, with the seed's tint that a
|
||||
// lift from black would lose.
|
||||
themeSetting.pureBlack
|
||||
? ref.watch(genColorSchemeProvider(brightness, pureBlack: false))
|
||||
: colorScheme.elevated,
|
||||
plainDark ?? colorScheme.elevated,
|
||||
),
|
||||
],
|
||||
).withAppShapes.withAppSurfaces.withAppTypography;
|
||||
_appThemes[brightness] = (inputs: inputs, theme: theme);
|
||||
return theme;
|
||||
}
|
||||
|
||||
class ThemeManager extends ConsumerWidget {
|
||||
|
||||
+28
-2
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -423,16 +424,41 @@ abstract class TextScale with _$TextScale {
|
||||
_$TextScaleFromJson(json);
|
||||
}
|
||||
|
||||
// Older configs name one of Material's nine scheme variants instead.
|
||||
Object? _readColorStyle(Map<dynamic, dynamic> json, String key) {
|
||||
if (json.containsKey(key)) {
|
||||
return json[key];
|
||||
}
|
||||
return switch (json['schemeVariant']) {
|
||||
'monochrome' => ColorStyle.monochrome.name,
|
||||
'tonalSpot' || 'neutral' => ColorStyle.soft.name,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Older configs say whether glass is on.
|
||||
Object? _readLiquidGlass(Map<dynamic, dynamic> json, String key) {
|
||||
return switch (json[key]) {
|
||||
true => LiquidGlassMode.standard.name,
|
||||
false => LiquidGlassMode.none.name,
|
||||
final value => value,
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ThemeProps with _$ThemeProps {
|
||||
const factory ThemeProps({
|
||||
int? primaryColor,
|
||||
@Default(defaultPrimaryColors) List<int> primaryColors,
|
||||
@Default(ThemeMode.system) ThemeMode themeMode,
|
||||
@Default(DynamicSchemeVariant.content) DynamicSchemeVariant schemeVariant,
|
||||
@Default(ColorStyle.vivid)
|
||||
@JsonKey(readValue: _readColorStyle)
|
||||
ColorStyle colorStyle,
|
||||
@Default(false) bool pureBlack,
|
||||
@Default(true) bool sidebarBlur,
|
||||
@Default(false) bool liquidGlass,
|
||||
@Default(LiquidGlassMode.none)
|
||||
@JsonKey(readValue: _readLiquidGlass)
|
||||
LiquidGlassMode liquidGlass,
|
||||
@Default(TextScale()) TextScale textScale,
|
||||
// `fontFamily` held the names of a removed enum, which old configs may
|
||||
// still carry.
|
||||
|
||||
@@ -2713,7 +2713,7 @@ as double,
|
||||
/// @nodoc
|
||||
mixin _$ThemeProps {
|
||||
|
||||
int? get primaryColor; List<int> get primaryColors; ThemeMode get themeMode; DynamicSchemeVariant get schemeVariant; bool get pureBlack; bool get sidebarBlur; bool get liquidGlass; TextScale get textScale;@JsonKey(name: 'systemFontFamily') String? get fontFamily;
|
||||
int? get primaryColor; List<int> get primaryColors; ThemeMode get themeMode;@JsonKey(readValue: _readColorStyle) ColorStyle get colorStyle; bool get pureBlack; bool get sidebarBlur;@JsonKey(readValue: _readLiquidGlass) LiquidGlassMode get liquidGlass; TextScale get textScale;@JsonKey(name: 'systemFontFamily') String? get fontFamily;
|
||||
/// Create a copy of ThemeProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -2727,20 +2727,20 @@ $ThemePropsCopyWith<ThemeProps> get copyWith => _$ThemePropsCopyWithImpl<ThemePr
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as ThemeProps;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeProps&&(identical(other.primaryColor, _this.primaryColor) || other.primaryColor == _this.primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _this.primaryColors)&&(identical(other.themeMode, _this.themeMode) || other.themeMode == _this.themeMode)&&(identical(other.schemeVariant, _this.schemeVariant) || other.schemeVariant == _this.schemeVariant)&&(identical(other.pureBlack, _this.pureBlack) || other.pureBlack == _this.pureBlack)&&(identical(other.sidebarBlur, _this.sidebarBlur) || other.sidebarBlur == _this.sidebarBlur)&&(identical(other.liquidGlass, _this.liquidGlass) || other.liquidGlass == _this.liquidGlass)&&(identical(other.textScale, _this.textScale) || other.textScale == _this.textScale)&&(identical(other.fontFamily, _this.fontFamily) || other.fontFamily == _this.fontFamily));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeProps&&(identical(other.primaryColor, _this.primaryColor) || other.primaryColor == _this.primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _this.primaryColors)&&(identical(other.themeMode, _this.themeMode) || other.themeMode == _this.themeMode)&&(identical(other.colorStyle, _this.colorStyle) || other.colorStyle == _this.colorStyle)&&(identical(other.pureBlack, _this.pureBlack) || other.pureBlack == _this.pureBlack)&&(identical(other.sidebarBlur, _this.sidebarBlur) || other.sidebarBlur == _this.sidebarBlur)&&(identical(other.liquidGlass, _this.liquidGlass) || other.liquidGlass == _this.liquidGlass)&&(identical(other.textScale, _this.textScale) || other.textScale == _this.textScale)&&(identical(other.fontFamily, _this.fontFamily) || other.fontFamily == _this.fontFamily));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as ThemeProps;
|
||||
return Object.hash(runtimeType,_this.primaryColor,const DeepCollectionEquality().hash(_this.primaryColors),_this.themeMode,_this.schemeVariant,_this.pureBlack,_this.sidebarBlur,_this.liquidGlass,_this.textScale,_this.fontFamily);
|
||||
return Object.hash(runtimeType,_this.primaryColor,const DeepCollectionEquality().hash(_this.primaryColors),_this.themeMode,_this.colorStyle,_this.pureBlack,_this.sidebarBlur,_this.liquidGlass,_this.textScale,_this.fontFamily);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as ThemeProps;
|
||||
return 'ThemeProps(primaryColor: ${_this.primaryColor}, primaryColors: ${_this.primaryColors}, themeMode: ${_this.themeMode}, schemeVariant: ${_this.schemeVariant}, pureBlack: ${_this.pureBlack}, sidebarBlur: ${_this.sidebarBlur}, liquidGlass: ${_this.liquidGlass}, textScale: ${_this.textScale}, fontFamily: ${_this.fontFamily})';
|
||||
return 'ThemeProps(primaryColor: ${_this.primaryColor}, primaryColors: ${_this.primaryColors}, themeMode: ${_this.themeMode}, colorStyle: ${_this.colorStyle}, pureBlack: ${_this.pureBlack}, sidebarBlur: ${_this.sidebarBlur}, liquidGlass: ${_this.liquidGlass}, textScale: ${_this.textScale}, fontFamily: ${_this.fontFamily})';
|
||||
}
|
||||
|
||||
|
||||
@@ -2751,7 +2751,7 @@ abstract mixin class $ThemePropsCopyWith<$Res> {
|
||||
factory $ThemePropsCopyWith(ThemeProps value, $Res Function(ThemeProps) _then) = _$ThemePropsCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
int? primaryColor, List<int> primaryColors, ThemeMode themeMode, DynamicSchemeVariant schemeVariant, bool pureBlack, bool sidebarBlur, bool liquidGlass, TextScale textScale,@JsonKey(name: 'systemFontFamily') String? fontFamily
|
||||
int? primaryColor, List<int> primaryColors, ThemeMode themeMode,@JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, bool pureBlack, bool sidebarBlur,@JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, TextScale textScale,@JsonKey(name: 'systemFontFamily') String? fontFamily
|
||||
});
|
||||
|
||||
|
||||
@@ -2768,16 +2768,16 @@ class _$ThemePropsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ThemeProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? themeMode = null,Object? schemeVariant = null,Object? pureBlack = null,Object? sidebarBlur = null,Object? liquidGlass = null,Object? textScale = null,Object? fontFamily = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? themeMode = null,Object? colorStyle = null,Object? pureBlack = null,Object? sidebarBlur = null,Object? liquidGlass = null,Object? textScale = null,Object? fontFamily = freezed,}) {
|
||||
return _then(ThemeProps(
|
||||
primaryColor: freezed == primaryColor ? _self.primaryColor : primaryColor // ignore: cast_nullable_to_non_nullable
|
||||
as int?,primaryColors: null == primaryColors ? _self.primaryColors : primaryColors // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,themeMode: null == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as ThemeMode,schemeVariant: null == schemeVariant ? _self.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable
|
||||
as DynamicSchemeVariant,pureBlack: null == pureBlack ? _self.pureBlack : pureBlack // ignore: cast_nullable_to_non_nullable
|
||||
as ThemeMode,colorStyle: null == colorStyle ? _self.colorStyle : colorStyle // ignore: cast_nullable_to_non_nullable
|
||||
as ColorStyle,pureBlack: null == pureBlack ? _self.pureBlack : pureBlack // ignore: cast_nullable_to_non_nullable
|
||||
as bool,sidebarBlur: null == sidebarBlur ? _self.sidebarBlur : sidebarBlur // ignore: cast_nullable_to_non_nullable
|
||||
as bool,liquidGlass: null == liquidGlass ? _self.liquidGlass : liquidGlass // ignore: cast_nullable_to_non_nullable
|
||||
as bool,textScale: null == textScale ? _self.textScale : textScale // ignore: cast_nullable_to_non_nullable
|
||||
as LiquidGlassMode,textScale: null == textScale ? _self.textScale : textScale // ignore: cast_nullable_to_non_nullable
|
||||
as TextScale,fontFamily: freezed == fontFamily ? _self.fontFamily : fontFamily // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
@@ -2873,10 +2873,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, DynamicSchemeVariant schemeVariant, bool pureBlack, bool sidebarBlur, bool liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, @JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, bool pureBlack, bool sidebarBlur, @JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeProps() when $default != null:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.schemeVariant,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.colorStyle,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -2894,10 +2894,10 @@ return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.sch
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, DynamicSchemeVariant schemeVariant, bool pureBlack, bool sidebarBlur, bool liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, @JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, bool pureBlack, bool sidebarBlur, @JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeProps():
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.schemeVariant,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.colorStyle,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -2914,10 +2914,10 @@ return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.sch
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, DynamicSchemeVariant schemeVariant, bool pureBlack, bool sidebarBlur, bool liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primaryColor, List<int> primaryColors, ThemeMode themeMode, @JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, bool pureBlack, bool sidebarBlur, @JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, TextScale textScale, @JsonKey(name: 'systemFontFamily') String? fontFamily)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeProps() when $default != null:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.schemeVariant,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.colorStyle,_that.pureBlack,_that.sidebarBlur,_that.liquidGlass,_that.textScale,_that.fontFamily);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -2929,7 +2929,7 @@ return $default(_that.primaryColor,_that.primaryColors,_that.themeMode,_that.sch
|
||||
@JsonSerializable()
|
||||
|
||||
class _ThemeProps implements ThemeProps {
|
||||
const _ThemeProps({this.primaryColor, List<int> primaryColors = defaultPrimaryColors, this.themeMode = ThemeMode.system, this.schemeVariant = DynamicSchemeVariant.content, this.pureBlack = false, this.sidebarBlur = true, this.liquidGlass = false, this.textScale = const TextScale(), @JsonKey(name: 'systemFontFamily') this.fontFamily}): _primaryColors = primaryColors;
|
||||
const _ThemeProps({this.primaryColor, List<int> primaryColors = defaultPrimaryColors, this.themeMode = ThemeMode.system, @JsonKey(readValue: _readColorStyle) this.colorStyle = ColorStyle.vivid, this.pureBlack = false, this.sidebarBlur = true, @JsonKey(readValue: _readLiquidGlass) this.liquidGlass = LiquidGlassMode.none, this.textScale = const TextScale(), @JsonKey(name: 'systemFontFamily') this.fontFamily}): _primaryColors = primaryColors;
|
||||
factory _ThemeProps.fromJson(Map<String, dynamic> json) => _$ThemePropsFromJson(json);
|
||||
|
||||
@override final int? primaryColor;
|
||||
@@ -2941,10 +2941,10 @@ class _ThemeProps implements ThemeProps {
|
||||
}
|
||||
|
||||
@override@JsonKey() final ThemeMode themeMode;
|
||||
@override@JsonKey() final DynamicSchemeVariant schemeVariant;
|
||||
@override@JsonKey(readValue: _readColorStyle) final ColorStyle colorStyle;
|
||||
@override@JsonKey() final bool pureBlack;
|
||||
@override@JsonKey() final bool sidebarBlur;
|
||||
@override@JsonKey() final bool liquidGlass;
|
||||
@override@JsonKey(readValue: _readLiquidGlass) final LiquidGlassMode liquidGlass;
|
||||
@override@JsonKey() final TextScale textScale;
|
||||
@override@JsonKey(name: 'systemFontFamily') final String? fontFamily;
|
||||
|
||||
@@ -2961,18 +2961,18 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeProps&&(identical(other.primaryColor, primaryColor) || other.primaryColor == primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _primaryColors)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.schemeVariant, schemeVariant) || other.schemeVariant == schemeVariant)&&(identical(other.pureBlack, pureBlack) || other.pureBlack == pureBlack)&&(identical(other.sidebarBlur, sidebarBlur) || other.sidebarBlur == sidebarBlur)&&(identical(other.liquidGlass, liquidGlass) || other.liquidGlass == liquidGlass)&&(identical(other.textScale, textScale) || other.textScale == textScale)&&(identical(other.fontFamily, fontFamily) || other.fontFamily == fontFamily));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeProps&&(identical(other.primaryColor, primaryColor) || other.primaryColor == primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _primaryColors)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.colorStyle, colorStyle) || other.colorStyle == colorStyle)&&(identical(other.pureBlack, pureBlack) || other.pureBlack == pureBlack)&&(identical(other.sidebarBlur, sidebarBlur) || other.sidebarBlur == sidebarBlur)&&(identical(other.liquidGlass, liquidGlass) || other.liquidGlass == liquidGlass)&&(identical(other.textScale, textScale) || other.textScale == textScale)&&(identical(other.fontFamily, fontFamily) || other.fontFamily == fontFamily));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,primaryColor,const DeepCollectionEquality().hash(_primaryColors),themeMode,schemeVariant,pureBlack,sidebarBlur,liquidGlass,textScale,fontFamily);
|
||||
return Object.hash(runtimeType,primaryColor,const DeepCollectionEquality().hash(_primaryColors),themeMode,colorStyle,pureBlack,sidebarBlur,liquidGlass,textScale,fontFamily);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThemeProps(primaryColor: $primaryColor, primaryColors: $primaryColors, themeMode: $themeMode, schemeVariant: $schemeVariant, pureBlack: $pureBlack, sidebarBlur: $sidebarBlur, liquidGlass: $liquidGlass, textScale: $textScale, fontFamily: $fontFamily)';
|
||||
return 'ThemeProps(primaryColor: $primaryColor, primaryColors: $primaryColors, themeMode: $themeMode, colorStyle: $colorStyle, pureBlack: $pureBlack, sidebarBlur: $sidebarBlur, liquidGlass: $liquidGlass, textScale: $textScale, fontFamily: $fontFamily)';
|
||||
}
|
||||
|
||||
|
||||
@@ -2983,7 +2983,7 @@ abstract mixin class _$ThemePropsCopyWith<$Res> implements $ThemePropsCopyWith<$
|
||||
factory _$ThemePropsCopyWith(_ThemeProps value, $Res Function(_ThemeProps) _then) = __$ThemePropsCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
int? primaryColor, List<int> primaryColors, ThemeMode themeMode, DynamicSchemeVariant schemeVariant, bool pureBlack, bool sidebarBlur, bool liquidGlass, TextScale textScale,@JsonKey(name: 'systemFontFamily') String? fontFamily
|
||||
int? primaryColor, List<int> primaryColors, ThemeMode themeMode,@JsonKey(readValue: _readColorStyle) ColorStyle colorStyle, bool pureBlack, bool sidebarBlur,@JsonKey(readValue: _readLiquidGlass) LiquidGlassMode liquidGlass, TextScale textScale,@JsonKey(name: 'systemFontFamily') String? fontFamily
|
||||
});
|
||||
|
||||
|
||||
@@ -3000,16 +3000,16 @@ class __$ThemePropsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ThemeProps
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? themeMode = null,Object? schemeVariant = null,Object? pureBlack = null,Object? sidebarBlur = null,Object? liquidGlass = null,Object? textScale = null,Object? fontFamily = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? themeMode = null,Object? colorStyle = null,Object? pureBlack = null,Object? sidebarBlur = null,Object? liquidGlass = null,Object? textScale = null,Object? fontFamily = freezed,}) {
|
||||
return _then(_ThemeProps(
|
||||
primaryColor: freezed == primaryColor ? _self.primaryColor : primaryColor // ignore: cast_nullable_to_non_nullable
|
||||
as int?,primaryColors: null == primaryColors ? _self._primaryColors : primaryColors // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,themeMode: null == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as ThemeMode,schemeVariant: null == schemeVariant ? _self.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable
|
||||
as DynamicSchemeVariant,pureBlack: null == pureBlack ? _self.pureBlack : pureBlack // ignore: cast_nullable_to_non_nullable
|
||||
as ThemeMode,colorStyle: null == colorStyle ? _self.colorStyle : colorStyle // ignore: cast_nullable_to_non_nullable
|
||||
as ColorStyle,pureBlack: null == pureBlack ? _self.pureBlack : pureBlack // ignore: cast_nullable_to_non_nullable
|
||||
as bool,sidebarBlur: null == sidebarBlur ? _self.sidebarBlur : sidebarBlur // ignore: cast_nullable_to_non_nullable
|
||||
as bool,liquidGlass: null == liquidGlass ? _self.liquidGlass : liquidGlass // ignore: cast_nullable_to_non_nullable
|
||||
as bool,textScale: null == textScale ? _self.textScale : textScale // ignore: cast_nullable_to_non_nullable
|
||||
as LiquidGlassMode,textScale: null == textScale ? _self.textScale : textScale // ignore: cast_nullable_to_non_nullable
|
||||
as TextScale,fontFamily: freezed == fontFamily ? _self.fontFamily : fontFamily // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
|
||||
@@ -357,15 +357,20 @@ _ThemeProps _$ThemePropsFromJson(Map<String, dynamic> json) => _ThemeProps(
|
||||
themeMode:
|
||||
$enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
|
||||
ThemeMode.system,
|
||||
schemeVariant:
|
||||
colorStyle:
|
||||
$enumDecodeNullable(
|
||||
_$DynamicSchemeVariantEnumMap,
|
||||
json['schemeVariant'],
|
||||
_$ColorStyleEnumMap,
|
||||
_readColorStyle(json, 'colorStyle'),
|
||||
) ??
|
||||
DynamicSchemeVariant.content,
|
||||
ColorStyle.vivid,
|
||||
pureBlack: json['pureBlack'] as bool? ?? false,
|
||||
sidebarBlur: json['sidebarBlur'] as bool? ?? true,
|
||||
liquidGlass: json['liquidGlass'] as bool? ?? false,
|
||||
liquidGlass:
|
||||
$enumDecodeNullable(
|
||||
_$LiquidGlassModeEnumMap,
|
||||
_readLiquidGlass(json, 'liquidGlass'),
|
||||
) ??
|
||||
LiquidGlassMode.none,
|
||||
textScale: json['textScale'] == null
|
||||
? const TextScale()
|
||||
: TextScale.fromJson(json['textScale'] as Map<String, dynamic>),
|
||||
@@ -377,10 +382,10 @@ Map<String, dynamic> _$ThemePropsToJson(_ThemeProps instance) =>
|
||||
'primaryColor': instance.primaryColor,
|
||||
'primaryColors': instance.primaryColors,
|
||||
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
|
||||
'schemeVariant': _$DynamicSchemeVariantEnumMap[instance.schemeVariant]!,
|
||||
'colorStyle': _$ColorStyleEnumMap[instance.colorStyle]!,
|
||||
'pureBlack': instance.pureBlack,
|
||||
'sidebarBlur': instance.sidebarBlur,
|
||||
'liquidGlass': instance.liquidGlass,
|
||||
'liquidGlass': _$LiquidGlassModeEnumMap[instance.liquidGlass]!,
|
||||
'textScale': instance.textScale,
|
||||
'systemFontFamily': instance.fontFamily,
|
||||
};
|
||||
@@ -391,16 +396,16 @@ const _$ThemeModeEnumMap = {
|
||||
ThemeMode.dark: 'dark',
|
||||
};
|
||||
|
||||
const _$DynamicSchemeVariantEnumMap = {
|
||||
DynamicSchemeVariant.tonalSpot: 'tonalSpot',
|
||||
DynamicSchemeVariant.fidelity: 'fidelity',
|
||||
DynamicSchemeVariant.monochrome: 'monochrome',
|
||||
DynamicSchemeVariant.neutral: 'neutral',
|
||||
DynamicSchemeVariant.vibrant: 'vibrant',
|
||||
DynamicSchemeVariant.expressive: 'expressive',
|
||||
DynamicSchemeVariant.content: 'content',
|
||||
DynamicSchemeVariant.rainbow: 'rainbow',
|
||||
DynamicSchemeVariant.fruitSalad: 'fruitSalad',
|
||||
const _$ColorStyleEnumMap = {
|
||||
ColorStyle.vivid: 'vivid',
|
||||
ColorStyle.tinted: 'tinted',
|
||||
ColorStyle.soft: 'soft',
|
||||
ColorStyle.monochrome: 'monochrome',
|
||||
};
|
||||
|
||||
const _$LiquidGlassModeEnumMap = {
|
||||
LiquidGlassMode.none: 'none',
|
||||
LiquidGlassMode.standard: 'standard',
|
||||
};
|
||||
|
||||
_Config _$ConfigFromJson(Map<String, dynamic> json) => _Config(
|
||||
|
||||
@@ -3918,7 +3918,7 @@ as int,
|
||||
/// @nodoc
|
||||
mixin _$ThemeColorsSelectorState {
|
||||
|
||||
int? get primaryColor; List<int> get primaryColors; DynamicSchemeVariant get schemeVariant; bool get isDefault;
|
||||
int? get primaryColor; List<int> get primaryColors; ColorStyle get colorStyle; bool get isDefault;
|
||||
/// Create a copy of ThemeColorsSelectorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -3930,20 +3930,20 @@ $ThemeColorsSelectorStateCopyWith<ThemeColorsSelectorState> get copyWith => _$Th
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as ThemeColorsSelectorState;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeColorsSelectorState&&(identical(other.primaryColor, _this.primaryColor) || other.primaryColor == _this.primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _this.primaryColors)&&(identical(other.schemeVariant, _this.schemeVariant) || other.schemeVariant == _this.schemeVariant)&&(identical(other.isDefault, _this.isDefault) || other.isDefault == _this.isDefault));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeColorsSelectorState&&(identical(other.primaryColor, _this.primaryColor) || other.primaryColor == _this.primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _this.primaryColors)&&(identical(other.colorStyle, _this.colorStyle) || other.colorStyle == _this.colorStyle)&&(identical(other.isDefault, _this.isDefault) || other.isDefault == _this.isDefault));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as ThemeColorsSelectorState;
|
||||
return Object.hash(runtimeType,_this.primaryColor,const DeepCollectionEquality().hash(_this.primaryColors),_this.schemeVariant,_this.isDefault);
|
||||
return Object.hash(runtimeType,_this.primaryColor,const DeepCollectionEquality().hash(_this.primaryColors),_this.colorStyle,_this.isDefault);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as ThemeColorsSelectorState;
|
||||
return 'ThemeColorsSelectorState(primaryColor: ${_this.primaryColor}, primaryColors: ${_this.primaryColors}, schemeVariant: ${_this.schemeVariant}, isDefault: ${_this.isDefault})';
|
||||
return 'ThemeColorsSelectorState(primaryColor: ${_this.primaryColor}, primaryColors: ${_this.primaryColors}, colorStyle: ${_this.colorStyle}, isDefault: ${_this.isDefault})';
|
||||
}
|
||||
|
||||
|
||||
@@ -3954,7 +3954,7 @@ abstract mixin class $ThemeColorsSelectorStateCopyWith<$Res> {
|
||||
factory $ThemeColorsSelectorStateCopyWith(ThemeColorsSelectorState value, $Res Function(ThemeColorsSelectorState) _then) = _$ThemeColorsSelectorStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
int? primaryColor, List<int> primaryColors, DynamicSchemeVariant schemeVariant, bool isDefault
|
||||
int? primaryColor, List<int> primaryColors, ColorStyle colorStyle, bool isDefault
|
||||
});
|
||||
|
||||
|
||||
@@ -3971,12 +3971,12 @@ class _$ThemeColorsSelectorStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ThemeColorsSelectorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? schemeVariant = null,Object? isDefault = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? colorStyle = null,Object? isDefault = null,}) {
|
||||
return _then(ThemeColorsSelectorState(
|
||||
primaryColor: freezed == primaryColor ? _self.primaryColor : primaryColor // ignore: cast_nullable_to_non_nullable
|
||||
as int?,primaryColors: null == primaryColors ? _self.primaryColors : primaryColors // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,schemeVariant: null == schemeVariant ? _self.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable
|
||||
as DynamicSchemeVariant,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,colorStyle: null == colorStyle ? _self.colorStyle : colorStyle // ignore: cast_nullable_to_non_nullable
|
||||
as ColorStyle,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
@@ -4062,10 +4062,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, DynamicSchemeVariant schemeVariant, bool isDefault)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ColorStyle colorStyle, bool isDefault)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeColorsSelectorState() when $default != null:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that.isDefault);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.colorStyle,_that.isDefault);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -4083,10 +4083,10 @@ return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, DynamicSchemeVariant schemeVariant, bool isDefault) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primaryColor, List<int> primaryColors, ColorStyle colorStyle, bool isDefault) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeColorsSelectorState():
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that.isDefault);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.colorStyle,_that.isDefault);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -4103,10 +4103,10 @@ return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primaryColor, List<int> primaryColors, DynamicSchemeVariant schemeVariant, bool isDefault)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primaryColor, List<int> primaryColors, ColorStyle colorStyle, bool isDefault)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ThemeColorsSelectorState() when $default != null:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that.isDefault);case _:
|
||||
return $default(_that.primaryColor,_that.primaryColors,_that.colorStyle,_that.isDefault);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -4118,7 +4118,7 @@ return $default(_that.primaryColor,_that.primaryColors,_that.schemeVariant,_that
|
||||
|
||||
|
||||
class _ThemeColorsSelectorState implements ThemeColorsSelectorState {
|
||||
const _ThemeColorsSelectorState({required this.primaryColor, required List<int> primaryColors, required this.schemeVariant, required this.isDefault}): _primaryColors = primaryColors;
|
||||
const _ThemeColorsSelectorState({required this.primaryColor, required List<int> primaryColors, required this.colorStyle, required this.isDefault}): _primaryColors = primaryColors;
|
||||
|
||||
|
||||
@override final int? primaryColor;
|
||||
@@ -4129,7 +4129,7 @@ class _ThemeColorsSelectorState implements ThemeColorsSelectorState {
|
||||
return EqualUnmodifiableListView(_primaryColors);
|
||||
}
|
||||
|
||||
@override final DynamicSchemeVariant schemeVariant;
|
||||
@override final ColorStyle colorStyle;
|
||||
@override final bool isDefault;
|
||||
|
||||
/// Create a copy of ThemeColorsSelectorState
|
||||
@@ -4142,18 +4142,18 @@ _$ThemeColorsSelectorStateCopyWith<_ThemeColorsSelectorState> get copyWith => __
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeColorsSelectorState&&(identical(other.primaryColor, primaryColor) || other.primaryColor == primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _primaryColors)&&(identical(other.schemeVariant, schemeVariant) || other.schemeVariant == schemeVariant)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeColorsSelectorState&&(identical(other.primaryColor, primaryColor) || other.primaryColor == primaryColor)&&const DeepCollectionEquality().equals(other.primaryColors, _primaryColors)&&(identical(other.colorStyle, colorStyle) || other.colorStyle == colorStyle)&&(identical(other.isDefault, isDefault) || other.isDefault == isDefault));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,primaryColor,const DeepCollectionEquality().hash(_primaryColors),schemeVariant,isDefault);
|
||||
return Object.hash(runtimeType,primaryColor,const DeepCollectionEquality().hash(_primaryColors),colorStyle,isDefault);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThemeColorsSelectorState(primaryColor: $primaryColor, primaryColors: $primaryColors, schemeVariant: $schemeVariant, isDefault: $isDefault)';
|
||||
return 'ThemeColorsSelectorState(primaryColor: $primaryColor, primaryColors: $primaryColors, colorStyle: $colorStyle, isDefault: $isDefault)';
|
||||
}
|
||||
|
||||
|
||||
@@ -4164,7 +4164,7 @@ abstract mixin class _$ThemeColorsSelectorStateCopyWith<$Res> implements $ThemeC
|
||||
factory _$ThemeColorsSelectorStateCopyWith(_ThemeColorsSelectorState value, $Res Function(_ThemeColorsSelectorState) _then) = __$ThemeColorsSelectorStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
int? primaryColor, List<int> primaryColors, DynamicSchemeVariant schemeVariant, bool isDefault
|
||||
int? primaryColor, List<int> primaryColors, ColorStyle colorStyle, bool isDefault
|
||||
});
|
||||
|
||||
|
||||
@@ -4181,12 +4181,12 @@ class __$ThemeColorsSelectorStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ThemeColorsSelectorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? schemeVariant = null,Object? isDefault = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? primaryColor = freezed,Object? primaryColors = null,Object? colorStyle = null,Object? isDefault = null,}) {
|
||||
return _then(_ThemeColorsSelectorState(
|
||||
primaryColor: freezed == primaryColor ? _self.primaryColor : primaryColor // ignore: cast_nullable_to_non_nullable
|
||||
as int?,primaryColors: null == primaryColors ? _self._primaryColors : primaryColors // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,schemeVariant: null == schemeVariant ? _self.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable
|
||||
as DynamicSchemeVariant,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,colorStyle: null == colorStyle ? _self.colorStyle : colorStyle // ignore: cast_nullable_to_non_nullable
|
||||
as ColorStyle,isDefault: null == isDefault ? _self.isDefault : isDefault // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -138,7 +139,7 @@ abstract class ThemeColorsSelectorState with _$ThemeColorsSelectorState {
|
||||
const factory ThemeColorsSelectorState({
|
||||
required int? primaryColor,
|
||||
required List<int> primaryColors,
|
||||
required DynamicSchemeVariant schemeVariant,
|
||||
required ColorStyle colorStyle,
|
||||
required bool isDefault,
|
||||
}) = _ThemeColorsSelectorState;
|
||||
}
|
||||
|
||||
@@ -688,7 +688,7 @@ final class GenColorSchemeProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$genColorSchemeHash() => r'c6e551ef2644606e7945acb21c5d721b5423f3d0';
|
||||
String _$genColorSchemeHash() => r'c6838e224d41780617f7338c5e56173a7620cd87';
|
||||
|
||||
final class GenColorSchemeFamily extends $Family
|
||||
with
|
||||
@@ -1849,14 +1849,22 @@ final class VisibleGroupsStateProvider
|
||||
}
|
||||
|
||||
String _$visibleGroupsStateHash() =>
|
||||
r'02b4a37b356f4484fa3787a08a239ddfe77769f0';
|
||||
r'b9f560285e114ebb98625daeb3fe418926e2e9c2';
|
||||
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
|
||||
@ProviderFor(filterGroupsState)
|
||||
final filterGroupsStateProvider = FilterGroupsStateFamily._();
|
||||
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
|
||||
final class FilterGroupsStateProvider
|
||||
extends $FunctionalProvider<GroupsState, GroupsState, GroupsState>
|
||||
with $Provider<GroupsState> {
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
FilterGroupsStateProvider._({
|
||||
required FilterGroupsStateFamily super.from,
|
||||
required String super.argument,
|
||||
@@ -1908,7 +1916,10 @@ final class FilterGroupsStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$filterGroupsStateHash() => r'187b758f3bddcf66e429eda99dfcb4254f9e4583';
|
||||
String _$filterGroupsStateHash() => r'd27c043b7647ce7f6182f2e5d4a880d987d2d5af';
|
||||
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
|
||||
final class FilterGroupsStateFamily extends $Family
|
||||
with $FunctionalFamilyOverride<GroupsState, String> {
|
||||
@@ -1921,6 +1932,9 @@ final class FilterGroupsStateFamily extends $Family
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Searches before hiding, so a group whose matches all timed out keeps them,
|
||||
/// and with them its delay test, instead of dropping out of the results.
|
||||
|
||||
FilterGroupsStateProvider call(String query) =>
|
||||
FilterGroupsStateProvider._(argument: query, from: this);
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ typedef DynamicColorSeeds = ({
|
||||
Color accentColor,
|
||||
});
|
||||
|
||||
extension DynamicColorSeedsExt on DynamicColorSeeds {
|
||||
Color seedOf(Brightness brightness) =>
|
||||
switch (brightness) {
|
||||
Brightness.light => lightSeed,
|
||||
Brightness.dark => darkSeed,
|
||||
} ??
|
||||
accentColor;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class DynamicColor extends _$DynamicColor {
|
||||
@override
|
||||
@@ -38,7 +47,7 @@ ColorScheme genColorScheme(
|
||||
themeSettingProvider.select(
|
||||
(state) => (
|
||||
primaryColor: state.primaryColor,
|
||||
schemeVariant: state.schemeVariant,
|
||||
colorStyle: state.colorStyle,
|
||||
pureBlack: state.pureBlack,
|
||||
),
|
||||
),
|
||||
@@ -47,40 +56,35 @@ ColorScheme genColorScheme(
|
||||
final Color seedColor;
|
||||
if (color == null &&
|
||||
(ignoreConfig == true || themeSetting.primaryColor == null)) {
|
||||
final seed = switch (brightness) {
|
||||
Brightness.light => dynamicColor.lightSeed,
|
||||
Brightness.dark => dynamicColor.darkSeed,
|
||||
};
|
||||
seedColor = seed ?? dynamicColor.accentColor;
|
||||
seedColor = dynamicColor.seedOf(brightness);
|
||||
} else {
|
||||
seedColor = color ?? Color(themeSetting.primaryColor!);
|
||||
}
|
||||
return _cachedColorScheme(
|
||||
seedColor,
|
||||
brightness,
|
||||
themeSetting.schemeVariant,
|
||||
themeSetting.colorStyle,
|
||||
pureBlack ?? themeSetting.pureBlack,
|
||||
);
|
||||
}
|
||||
|
||||
const _maxCachedColorSchemes = 64;
|
||||
|
||||
final _colorSchemes =
|
||||
<(Color, Brightness, DynamicSchemeVariant, bool), ColorScheme>{};
|
||||
final _colorSchemes = <(Color, Brightness, ColorStyle, bool), ColorScheme>{};
|
||||
|
||||
ColorScheme _cachedColorScheme(
|
||||
Color seedColor,
|
||||
Brightness brightness,
|
||||
DynamicSchemeVariant variant,
|
||||
ColorStyle style,
|
||||
bool pureBlack,
|
||||
) {
|
||||
final key = (seedColor, brightness, variant, pureBlack);
|
||||
final key = (seedColor, brightness, style, pureBlack);
|
||||
final colorScheme =
|
||||
_colorSchemes.remove(key) ??
|
||||
seededColorScheme(
|
||||
seedColor: seedColor,
|
||||
brightness: brightness,
|
||||
variant: variant,
|
||||
style: style,
|
||||
).toPureBlack(pureBlack);
|
||||
_colorSchemes[key] = colorScheme;
|
||||
if (_colorSchemes.length > _maxCachedColorSchemes) {
|
||||
|
||||
+152
-71
@@ -8,53 +8,103 @@ import 'package:material_color_utilities/material_color_utilities.dart'
|
||||
Hct,
|
||||
MaterialDynamicColors,
|
||||
SchemeContent,
|
||||
SchemeExpressive,
|
||||
SchemeFidelity,
|
||||
SchemeFruitSalad,
|
||||
SchemeMonochrome,
|
||||
SchemeNeutral,
|
||||
SchemeRainbow,
|
||||
SchemeTonalSpot,
|
||||
SchemeVibrant,
|
||||
TonalPalette;
|
||||
TonalPalette,
|
||||
Variant;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// `.agents/surfaces.md` explains why surfaces stay near gray and why
|
||||
/// `primaryContainer` leaves the seed's own tone.
|
||||
enum ColorStyle {
|
||||
vivid,
|
||||
tinted,
|
||||
soft,
|
||||
monochrome;
|
||||
|
||||
Variant get _variant => switch (this) {
|
||||
vivid || tinted => Variant.content,
|
||||
soft => Variant.tonalSpot,
|
||||
monochrome => Variant.monochrome,
|
||||
};
|
||||
}
|
||||
|
||||
/// `.agents/surfaces.md` explains why each role this replaces leaves
|
||||
/// Material's value.
|
||||
ColorScheme seededColorScheme({
|
||||
required Color seedColor,
|
||||
required Brightness brightness,
|
||||
DynamicSchemeVariant variant = DynamicSchemeVariant.tonalSpot,
|
||||
ColorStyle style = ColorStyle.vivid,
|
||||
}) {
|
||||
final dark = brightness == Brightness.dark;
|
||||
final scheme = _dynamicScheme(
|
||||
Hct.fromInt(seedColor.toARGB32()),
|
||||
dark,
|
||||
variant,
|
||||
);
|
||||
final source = Hct.fromInt(seedColor.toARGB32());
|
||||
final scheme = _dynamicScheme(source, dark, style);
|
||||
final primary = scheme.primaryPalette;
|
||||
final container = TonalPalette.of(
|
||||
primary.hue,
|
||||
min(primary.chroma, _primaryContainerChroma),
|
||||
);
|
||||
final tint = style == ColorStyle.tinted
|
||||
? min(source.chroma / _tintDivisor, _maxTintChroma)
|
||||
: null;
|
||||
Color neutral(DynamicColor role, double maxChroma) {
|
||||
final palette = role.palette(scheme);
|
||||
return Color(
|
||||
Hct.from(
|
||||
palette.hue,
|
||||
min(palette.chroma, maxChroma),
|
||||
tint ?? min(palette.chroma, maxChroma),
|
||||
role.getTone(scheme),
|
||||
).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
Color role(DynamicColor color) => Color(color.getArgb(scheme));
|
||||
|
||||
final paperChroma = dark ? _surfaceChroma : _paperChroma;
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: seedColor,
|
||||
final accent = switch (style) {
|
||||
ColorStyle.vivid || ColorStyle.tinted => _accent(seedColor, source, dark),
|
||||
ColorStyle.soft || ColorStyle.monochrome => null,
|
||||
};
|
||||
final primaryColor = accent?.primary ?? role(MaterialDynamicColors.primary);
|
||||
return ColorScheme(
|
||||
brightness: brightness,
|
||||
dynamicSchemeVariant: variant,
|
||||
primary: primaryColor,
|
||||
onPrimary: accent?.onPrimary ?? role(MaterialDynamicColors.onPrimary),
|
||||
surfaceTint: primaryColor,
|
||||
primaryContainer: Color(container.get(dark ? 30 : 90)),
|
||||
onPrimaryContainer: Color(container.get(dark ? 90 : 30)),
|
||||
primaryFixed: role(MaterialDynamicColors.primaryFixed),
|
||||
primaryFixedDim: role(MaterialDynamicColors.primaryFixedDim),
|
||||
onPrimaryFixed: role(MaterialDynamicColors.onPrimaryFixed),
|
||||
onPrimaryFixedVariant: role(MaterialDynamicColors.onPrimaryFixedVariant),
|
||||
secondary: role(MaterialDynamicColors.secondary),
|
||||
onSecondary: role(MaterialDynamicColors.onSecondary),
|
||||
secondaryContainer: role(MaterialDynamicColors.secondaryContainer),
|
||||
onSecondaryContainer: role(MaterialDynamicColors.onSecondaryContainer),
|
||||
secondaryFixed: role(MaterialDynamicColors.secondaryFixed),
|
||||
secondaryFixedDim: role(MaterialDynamicColors.secondaryFixedDim),
|
||||
onSecondaryFixed: role(MaterialDynamicColors.onSecondaryFixed),
|
||||
onSecondaryFixedVariant: role(
|
||||
MaterialDynamicColors.onSecondaryFixedVariant,
|
||||
),
|
||||
tertiary: role(MaterialDynamicColors.tertiary),
|
||||
onTertiary: role(MaterialDynamicColors.onTertiary),
|
||||
tertiaryContainer: role(MaterialDynamicColors.tertiaryContainer),
|
||||
onTertiaryContainer: role(MaterialDynamicColors.onTertiaryContainer),
|
||||
tertiaryFixed: role(MaterialDynamicColors.tertiaryFixed),
|
||||
tertiaryFixedDim: role(MaterialDynamicColors.tertiaryFixedDim),
|
||||
onTertiaryFixed: role(MaterialDynamicColors.onTertiaryFixed),
|
||||
onTertiaryFixedVariant: role(MaterialDynamicColors.onTertiaryFixedVariant),
|
||||
error: role(MaterialDynamicColors.error),
|
||||
onError: role(MaterialDynamicColors.onError),
|
||||
errorContainer: role(MaterialDynamicColors.errorContainer),
|
||||
onErrorContainer: role(MaterialDynamicColors.onErrorContainer),
|
||||
outline: role(MaterialDynamicColors.outline),
|
||||
onSurface: role(MaterialDynamicColors.onSurface),
|
||||
onSurfaceVariant: role(MaterialDynamicColors.onSurfaceVariant),
|
||||
inverseSurface: role(MaterialDynamicColors.inverseSurface),
|
||||
onInverseSurface: role(MaterialDynamicColors.inverseOnSurface),
|
||||
inversePrimary: role(MaterialDynamicColors.inversePrimary),
|
||||
shadow: role(MaterialDynamicColors.shadow),
|
||||
scrim: role(MaterialDynamicColors.scrim),
|
||||
surface: neutral(MaterialDynamicColors.surface, paperChroma),
|
||||
surfaceBright: neutral(MaterialDynamicColors.surfaceBright, paperChroma),
|
||||
surfaceDim: neutral(MaterialDynamicColors.surfaceDim, _surfaceChroma),
|
||||
@@ -86,61 +136,84 @@ const _surfaceChroma = 3.0;
|
||||
|
||||
const _paperChroma = 1.5;
|
||||
|
||||
const _tintDivisor = 6.0;
|
||||
|
||||
const _maxTintChroma = 10.0;
|
||||
|
||||
const _primaryContainerChroma = 48.0;
|
||||
|
||||
// Mirrors the private builder behind ColorScheme.fromSeed, so these palettes
|
||||
// are the ones the scheme it returns was drawn from.
|
||||
DynamicScheme _dynamicScheme(
|
||||
Hct source,
|
||||
const _minAccentTone = 35.0;
|
||||
|
||||
const _maxAccentTone = 60.0;
|
||||
|
||||
const _darkAccentLift = 3.0;
|
||||
|
||||
const _minDarkAccentTone = 50.0;
|
||||
|
||||
const _maxDarkAccentTone = 75.0;
|
||||
|
||||
({Color primary, Color onPrimary}) _accent(
|
||||
Color seedColor,
|
||||
Hct seed,
|
||||
bool dark,
|
||||
DynamicSchemeVariant variant,
|
||||
) => switch (variant) {
|
||||
DynamicSchemeVariant.tonalSpot => SchemeTonalSpot(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.fidelity => SchemeFidelity(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.content => SchemeContent(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.monochrome => SchemeMonochrome(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.neutral => SchemeNeutral(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.vibrant => SchemeVibrant(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.expressive => SchemeExpressive(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.rainbow => SchemeRainbow(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
DynamicSchemeVariant.fruitSalad => SchemeFruitSalad(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
};
|
||||
) {
|
||||
final tone = dark
|
||||
? clampDouble(
|
||||
seed.tone + _darkAccentLift,
|
||||
_minDarkAccentTone,
|
||||
_maxDarkAccentTone,
|
||||
)
|
||||
: clampDouble(seed.tone, _minAccentTone, _maxAccentTone);
|
||||
return (
|
||||
primary: tone == seed.tone
|
||||
? seedColor
|
||||
: Color(Hct.from(seed.hue, seed.chroma, tone).toInt()),
|
||||
onPrimary: tone <= _maxAccentTone
|
||||
? Colors.white
|
||||
: Color(TonalPalette.of(seed.hue, seed.chroma).get(10)),
|
||||
);
|
||||
}
|
||||
|
||||
DynamicScheme? _lastScheme;
|
||||
|
||||
// Palettes do not depend on brightness, and a seed's light and dark schemes
|
||||
// are built one after the other, so the second takes the first one's rather
|
||||
// than running the content variant's temperature analysis again.
|
||||
DynamicScheme _dynamicScheme(Hct source, bool dark, ColorStyle style) {
|
||||
final last = _lastScheme;
|
||||
final scheme =
|
||||
last != null &&
|
||||
last.sourceColorArgb == source.toInt() &&
|
||||
last.variant == style._variant
|
||||
? DynamicScheme(
|
||||
sourceColorHct: source,
|
||||
variant: last.variant,
|
||||
isDark: dark,
|
||||
primaryPalette: last.primaryPalette,
|
||||
secondaryPalette: last.secondaryPalette,
|
||||
tertiaryPalette: last.tertiaryPalette,
|
||||
neutralPalette: last.neutralPalette,
|
||||
neutralVariantPalette: last.neutralVariantPalette,
|
||||
)
|
||||
: switch (style) {
|
||||
ColorStyle.vivid || ColorStyle.tinted => SchemeContent(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
ColorStyle.soft => SchemeTonalSpot(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
ColorStyle.monochrome => SchemeMonochrome(
|
||||
sourceColorHct: source,
|
||||
isDark: dark,
|
||||
contrastLevel: 0,
|
||||
),
|
||||
};
|
||||
return _lastScheme = scheme;
|
||||
}
|
||||
|
||||
final _successPalette = TonalPalette.fromHct(
|
||||
Hct.fromInt(Colors.green.toARGB32()),
|
||||
@@ -234,6 +307,14 @@ extension ColorSchemeExtension on ColorScheme {
|
||||
|
||||
Color get modalScrim => scrim.withValues(alpha: 0.32);
|
||||
|
||||
/// How far iOS 27 dims behind an alert.
|
||||
Color get alertScrim =>
|
||||
scrim.withValues(alpha: brightness == Brightness.dark ? 0.48 : 0.2);
|
||||
|
||||
/// iOS 27's systemRed, which an alert's destructive action takes.
|
||||
Color get destructive =>
|
||||
Color(brightness == Brightness.dark ? 0xFFFF4245 : 0xFFFF383C);
|
||||
|
||||
Color _toneOf(TonalPalette palette, int light, int dark) =>
|
||||
Color(palette.get(brightness == Brightness.dark ? dark : light));
|
||||
|
||||
|
||||
@@ -5,50 +5,6 @@ import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/inherited.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class EffectGestureDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final GestureLongPressCallback? onLongPress;
|
||||
final GestureTapCallback? onSecondaryTap;
|
||||
|
||||
const EffectGestureDetector({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onLongPress,
|
||||
this.onSecondaryTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EffectGestureDetector> createState() => _EffectGestureDetectorState();
|
||||
}
|
||||
|
||||
class _EffectGestureDetectorState extends State<EffectGestureDetector> {
|
||||
double _scale = 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedScale(
|
||||
scale: _scale,
|
||||
duration: kThemeAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: GestureDetector(
|
||||
onLongPress: widget.onLongPress,
|
||||
onLongPressStart: (_) {
|
||||
setState(() {
|
||||
_scale = 0.95;
|
||||
});
|
||||
},
|
||||
onSecondaryTap: widget.onSecondaryTap,
|
||||
onLongPressEnd: (_) {
|
||||
setState(() {
|
||||
_scale = 1;
|
||||
});
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CommonExpandIcon extends StatefulWidget {
|
||||
final bool expand;
|
||||
|
||||
|
||||
@@ -17,14 +17,12 @@ class FormSheetRoute<T> extends PopupRoute<T>
|
||||
with SheetCoverRouteMixin<T>, AlertMotionRouteMixin<T>, AlertHostRoute<T> {
|
||||
FormSheetRoute({
|
||||
required this.builder,
|
||||
required this.capturedThemes,
|
||||
required this.barrierColor,
|
||||
required this.barrierLabel,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
final CapturedThemes capturedThemes;
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
@@ -57,13 +55,11 @@ class FormSheetRoute<T> extends PopupRoute<T>
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return capturedThemes.wrap(
|
||||
ElevatedSurfaceTheme(
|
||||
child: _FormSheet(
|
||||
backgroundColor: backgroundColor,
|
||||
dismissHandler: _dismissHandler,
|
||||
builder: builder,
|
||||
),
|
||||
return ElevatedSurfaceTheme(
|
||||
child: _FormSheet(
|
||||
backgroundColor: backgroundColor,
|
||||
dismissHandler: _dismissHandler,
|
||||
builder: builder,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+19
-16
@@ -325,27 +325,30 @@ class CommonScaffoldState extends State<CommonScaffold> {
|
||||
),
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
padding: const EdgeInsets.only(
|
||||
left: BottomInsetScope.dockedSearchMargin,
|
||||
right: BottomInsetScope.dockedSearchMargin,
|
||||
bottom:
|
||||
BottomInsetScope.dockedSearchMargin +
|
||||
MediaQuery.paddingOf(context).bottom,
|
||||
bottom: BottomInsetScope.dockedSearchMargin,
|
||||
),
|
||||
// A surface of its own, so the list runs under it, not a sheet-wide band.
|
||||
child: glass
|
||||
? LiquidGlass(
|
||||
style: LiquidGlassStyle.pill(colorScheme),
|
||||
shadows: dockGlassShadows(colorScheme),
|
||||
child: field,
|
||||
)
|
||||
: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.full,
|
||||
shadows: dockShadows(colorScheme),
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
top: false,
|
||||
right: false,
|
||||
child: glass
|
||||
? LiquidGlass(
|
||||
style: LiquidGlassStyle.pill(colorScheme),
|
||||
shadows: dockGlassShadows(colorScheme),
|
||||
child: field,
|
||||
)
|
||||
: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.full,
|
||||
shadows: dockShadows(colorScheme),
|
||||
),
|
||||
child: field,
|
||||
),
|
||||
child: field,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +74,6 @@ Future<T?> showFormSheet<T>({
|
||||
backgroundColor: props.backgroundColor,
|
||||
barrierColor: context.colorScheme.modalScrim,
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -95,10 +91,6 @@ Future<T?> _showBottomSheet<T>(
|
||||
fitsContent: true,
|
||||
sheetBarrierColor: context.colorScheme.modalScrim,
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -200,10 +192,6 @@ Future<T?> showSnapSheet<T>(
|
||||
barrierLabel: MaterialLocalizations.of(
|
||||
context,
|
||||
).modalBarrierDismissLabel,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
||||
+1
-10
@@ -311,7 +311,6 @@ class ModalSideSheetRoute<T> extends PopupRoute<T>
|
||||
with DragBackRouteMixin<T>, SheetCoverRouteMixin<T> {
|
||||
ModalSideSheetRoute({
|
||||
required this.builder,
|
||||
this.capturedThemes,
|
||||
this.barrierLabel,
|
||||
this.barrierOnTapHint,
|
||||
this.backgroundColor,
|
||||
@@ -320,8 +319,6 @@ class ModalSideSheetRoute<T> extends PopupRoute<T>
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
final CapturedThemes? capturedThemes;
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final Color? modalBarrierColor;
|
||||
@@ -384,9 +381,7 @@ class ModalSideSheetRoute<T> extends PopupRoute<T>
|
||||
),
|
||||
);
|
||||
|
||||
final Widget sideSheet = ElevatedSurfaceTheme(child: content);
|
||||
|
||||
return capturedThemes?.wrap(sideSheet) ?? sideSheet;
|
||||
return ElevatedSurfaceTheme(child: content);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -433,10 +428,6 @@ Future<T?> showModalSideSheet<T>({
|
||||
return navigator.push(
|
||||
ModalSideSheetRoute<T>(
|
||||
builder: builder,
|
||||
capturedThemes: InheritedTheme.capture(
|
||||
from: context,
|
||||
to: navigator.context,
|
||||
),
|
||||
barrierLabel: localizations.scrimLabel,
|
||||
barrierOnTapHint: localizations.scrimOnTapHint(
|
||||
localizations.bottomSheetLabel,
|
||||
|
||||
+109
-20
@@ -40,6 +40,8 @@ const _projectionTime = 0.99 / (1 - 0.99) / 1000;
|
||||
|
||||
const _settleTolerance = Tolerance(distance: 0.5, velocity: 10);
|
||||
|
||||
const _peakEase = 120.0;
|
||||
|
||||
/// [_settleTolerance] for a sheet about 500 pixels tall, in UIKit's 0.5 s.
|
||||
const _presentationTolerance = Tolerance(distance: 0.001, velocity: 0.02);
|
||||
|
||||
@@ -50,7 +52,6 @@ const _wheelTurnGap = Duration(milliseconds: 500);
|
||||
class SnapSheetRoute<T> extends PopupRoute<T> with SheetCoverRouteMixin<T> {
|
||||
SnapSheetRoute({
|
||||
required this.builder,
|
||||
required this.capturedThemes,
|
||||
required this.sheetBarrierColor,
|
||||
required this.barrierLabel,
|
||||
this.detents = snapSheetDetents,
|
||||
@@ -59,7 +60,6 @@ class SnapSheetRoute<T> extends PopupRoute<T> with SheetCoverRouteMixin<T> {
|
||||
});
|
||||
|
||||
final SnapSheetBuilder builder;
|
||||
final CapturedThemes capturedThemes;
|
||||
final Color sheetBarrierColor;
|
||||
final List<double> detents;
|
||||
|
||||
@@ -173,9 +173,7 @@ class SnapSheetRoute<T> extends PopupRoute<T> with SheetCoverRouteMixin<T> {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return capturedThemes.wrap(
|
||||
ElevatedSurfaceTheme(child: _SnapSheet(route: this)),
|
||||
);
|
||||
return ElevatedSurfaceTheme(child: _SnapSheet(route: this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +197,18 @@ class SnapSheetRoute<T> extends PopupRoute<T> with SheetCoverRouteMixin<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/// max(x, 0) with its corner rounded over [-width, width], never below it.
|
||||
double _rounded(double x, double width) {
|
||||
if (x >= width) {
|
||||
return x;
|
||||
}
|
||||
if (x <= -width) {
|
||||
return 0;
|
||||
}
|
||||
final reach = x + width;
|
||||
return reach * reach / (4 * width);
|
||||
}
|
||||
|
||||
/// SpringSimulation picks its solution by the sign of a discriminant that a
|
||||
/// critically damped spring leaves to rounding.
|
||||
class _SheetSpring extends Simulation {
|
||||
@@ -311,6 +321,9 @@ class _SheetModel extends ChangeNotifier {
|
||||
|
||||
/// Infinite while the content fills the height it was laid out within.
|
||||
double _fitHeight = double.infinity;
|
||||
double _filledRoom = 0;
|
||||
double? _measureRoom;
|
||||
double _contentRoom = 0;
|
||||
double _contentLimit = 0;
|
||||
double contentHeight = 0;
|
||||
_RenderSheetContent? _content;
|
||||
@@ -322,13 +335,12 @@ class _SheetModel extends ChangeNotifier {
|
||||
bool get _closing => _presentation.status == AnimationStatus.reverse;
|
||||
|
||||
/// The tallest detent, lifted by the keyboard up to a peek below the top.
|
||||
double get _top => math.max(
|
||||
math.min(
|
||||
_available - stackedSheetPeek,
|
||||
detents.last * _available + _keyboard,
|
||||
),
|
||||
0,
|
||||
);
|
||||
double get _top {
|
||||
final peak = math.max(_available - stackedSheetPeek, 0.0);
|
||||
final tallest = detents.last * _available;
|
||||
final room = clampDouble(peak - tallest, 0, _peakEase);
|
||||
return peak - _rounded(peak - tallest - _keyboard, room);
|
||||
}
|
||||
|
||||
/// A fit sheet follows its content up to each detent but never rests below
|
||||
/// the shortest one; each keeps what it shows above the keyboard.
|
||||
@@ -344,8 +356,8 @@ class _SheetModel extends ChangeNotifier {
|
||||
|
||||
double _stopFor(double detent, double shortest, double top) {
|
||||
final rest = clampDouble(_fitHeight, shortest, detent);
|
||||
final shown = math.min(_fitHeight, rest);
|
||||
return math.min(top, math.max(rest, shown + _keyboard));
|
||||
final blank = rest - math.min(_fitHeight, rest);
|
||||
return math.min(top, rest + _rounded(_keyboard - blank, blank / 2));
|
||||
}
|
||||
|
||||
double get minHeight => _stops.first;
|
||||
@@ -384,15 +396,31 @@ class _SheetModel extends ChangeNotifier {
|
||||
_Resting(:final stop) => stop,
|
||||
_ => null,
|
||||
};
|
||||
_contentLimit = measured == null || !fitsContent
|
||||
_contentRoom = measured == null || !fitsContent
|
||||
? math.max(height, stops.first)
|
||||
: math.max(
|
||||
math.min(detents[measured] * available, _top - keyboard),
|
||||
height,
|
||||
);
|
||||
if (!fitsContent || !_isShortOfLimit) {
|
||||
_contentLimit = _contentRoom;
|
||||
}
|
||||
return _contentLimit;
|
||||
}
|
||||
|
||||
/// Short content lays out the same within its room and the limit it had.
|
||||
bool get _isShortOfLimit =>
|
||||
contentHeight <
|
||||
math.min(_contentRoom, _contentLimit) - precisionErrorTolerance;
|
||||
|
||||
bool releaseLimit() {
|
||||
if (_contentLimit == _contentRoom || _isShortOfLimit) {
|
||||
return false;
|
||||
}
|
||||
_contentLimit = _contentRoom;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool fitContent() {
|
||||
final limit = _contentLimit;
|
||||
final fills = contentHeight >= limit - precisionErrorTolerance;
|
||||
@@ -401,13 +429,54 @@ class _SheetModel extends ChangeNotifier {
|
||||
: _fitHeight >= limit - precisionErrorTolerance
|
||||
? _fitHeight
|
||||
: double.infinity;
|
||||
return _refit(fit);
|
||||
}
|
||||
|
||||
double get _tallestRoom =>
|
||||
math.min(detents.last * _available, _top - _keyboard);
|
||||
|
||||
/// Content taller than the detent the sheet rests at is measured up to the
|
||||
/// tallest one before the sheet moves, so a release lands where it ends.
|
||||
void _measureBeforeMoving() {
|
||||
final room = _tallestRoom;
|
||||
if (!fitsContent ||
|
||||
_fitHeight.isFinite ||
|
||||
_contentLimit >= room - precisionErrorTolerance ||
|
||||
_filledRoom >= room - precisionErrorTolerance) {
|
||||
return;
|
||||
}
|
||||
_measureRoom = room;
|
||||
}
|
||||
|
||||
void _measured(double natural, double room) {
|
||||
final fills = natural >= room - precisionErrorTolerance;
|
||||
_refit(fills ? double.infinity : natural);
|
||||
if (fills) {
|
||||
_filledRoom = room;
|
||||
}
|
||||
}
|
||||
|
||||
bool _refit(double fit) {
|
||||
if (fit == _fitHeight) {
|
||||
return false;
|
||||
}
|
||||
_filledRoom = 0;
|
||||
final shown = height;
|
||||
_fitHeight = fit;
|
||||
_keepSettlingFrom(shown);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _keepSettlingFrom(double shown) {
|
||||
final activity = _activity;
|
||||
if (activity is! _Settling) {
|
||||
return;
|
||||
}
|
||||
final offset = shown - _stops[activity.stop];
|
||||
final launch = _launch(-offset, activity.velocity, overshoots: true);
|
||||
_activity = _Settling(activity.stop, offset, launch.velocity, launch.ratio);
|
||||
}
|
||||
|
||||
void endLayout() {
|
||||
_syncLift();
|
||||
_syncOpen();
|
||||
@@ -453,6 +522,9 @@ class _SheetModel extends ChangeNotifier {
|
||||
final height = this.height;
|
||||
_stopTicker();
|
||||
_activity = _Holding(height, dragged: dragged);
|
||||
if (dragged) {
|
||||
_measureBeforeMoving();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how much of [delta], positive to grow, the sheet took: what is
|
||||
@@ -572,6 +644,7 @@ class _SheetModel extends ChangeNotifier {
|
||||
}
|
||||
final launch = _launch(-offset, velocity, overshoots: true);
|
||||
_activity = _Settling(stop, offset, launch.velocity, launch.ratio);
|
||||
_measureBeforeMoving();
|
||||
settling.value = true;
|
||||
if (!_ticker.isActive) {
|
||||
_lastTick = SchedulerBinding.instance.currentSystemFrameTimeStamp;
|
||||
@@ -956,17 +1029,24 @@ class _RenderSheetViewport extends RenderBox
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final limit = _model.beginLayout(size.height, _keyboard);
|
||||
var limit = _model.beginLayout(size.height, _keyboard);
|
||||
final content = _model._content;
|
||||
// The limit reaches the content outside its constraints, which may not
|
||||
// have changed.
|
||||
if (content != null && content._limit != limit) {
|
||||
if (content != null &&
|
||||
(content._limit != limit || _model._measureRoom != null)) {
|
||||
invokeLayoutCallback<BoxConstraints>((_) => content.markNeedsLayout());
|
||||
}
|
||||
var height = _model.height;
|
||||
_layoutSheet(height);
|
||||
if (content != null && _model.releaseLimit()) {
|
||||
limit = _model._contentLimit;
|
||||
invokeLayoutCallback<BoxConstraints>((_) => content.markNeedsLayout());
|
||||
_layoutSheet(height);
|
||||
}
|
||||
// Within the limit only the sheet around the content needs a new layout.
|
||||
if (_model.fitsContent && _model.fitContent()) {
|
||||
if (_model.fitsContent) {
|
||||
_model.fitContent();
|
||||
final fitted = _model.height;
|
||||
if (fitted != height && fitted <= limit) {
|
||||
height = fitted;
|
||||
@@ -983,14 +1063,14 @@ class _RenderSheetViewport extends RenderBox
|
||||
BoxConstraints(
|
||||
minWidth: size.width,
|
||||
maxWidth: size.width,
|
||||
maxHeight: math.max(height, _model.minHeight) + _keyboard,
|
||||
maxHeight: size.height,
|
||||
),
|
||||
parentUsesSize: true,
|
||||
);
|
||||
}
|
||||
|
||||
Matrix4 get _transform {
|
||||
final sheet = child!.size.height;
|
||||
final sheet = math.max(_shown, _model.minHeight) + _keyboard;
|
||||
final cover = _covering.value == null ? 0.0 : _cover.value;
|
||||
final scale = 1 - (1 - stackedSheetScale) * cover;
|
||||
final below = math.max(_model.minHeight - _shown, 0.0);
|
||||
@@ -1107,6 +1187,15 @@ class _RenderSheetContent extends RenderProxyBox {
|
||||
final limit = _limit = model._contentLimit;
|
||||
final child = this.child;
|
||||
if (child != null) {
|
||||
final room = model._measureRoom;
|
||||
if (room != null) {
|
||||
model._measureRoom = null;
|
||||
child.layout(
|
||||
BoxConstraints(minWidth: width, maxWidth: width, maxHeight: room),
|
||||
parentUsesSize: true,
|
||||
);
|
||||
model._measured(child.size.height, room);
|
||||
}
|
||||
child.layout(
|
||||
model.fitsContent
|
||||
? BoxConstraints(minWidth: width, maxWidth: width, maxHeight: limit)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:ui' as ui show Image;
|
||||
|
||||
import 'package:fl_clash/ui/context.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Switches the theme in one frame and fades the frame before it out on top,
|
||||
/// instead of lerping [ThemeData] and rebuilding every dependent each frame.
|
||||
class ThemeCrossFade extends StatefulWidget {
|
||||
const ThemeCrossFade({
|
||||
super.key,
|
||||
this.duration = kThemeAnimationDuration,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Duration duration;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ThemeCrossFade> createState() => _ThemeCrossFadeState();
|
||||
}
|
||||
|
||||
class _ThemeCrossFadeState extends State<ThemeCrossFade>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
)..addStatusListener(_handleStatus);
|
||||
final _fadeKey = GlobalKey();
|
||||
ThemeData? _theme;
|
||||
ui.Image? _snapshot;
|
||||
Size? _snapshotSize;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final theme = Theme.of(context);
|
||||
final previous = _theme;
|
||||
_theme = theme;
|
||||
if (previous != null && previous != theme) {
|
||||
_capture();
|
||||
}
|
||||
}
|
||||
|
||||
void _capture() {
|
||||
if (context.disableAnimations) {
|
||||
return;
|
||||
}
|
||||
switch (WidgetsBinding.instance.lifecycleState) {
|
||||
case AppLifecycleState.hidden ||
|
||||
AppLifecycleState.paused ||
|
||||
AppLifecycleState.detached:
|
||||
return;
|
||||
case _:
|
||||
}
|
||||
final fade = _fadeKey.currentContext?.findRenderObject();
|
||||
if (fade is! _RenderThemeCrossFade) {
|
||||
return;
|
||||
}
|
||||
final image = fade.capture(MediaQuery.devicePixelRatioOf(context));
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
_snapshot?.dispose();
|
||||
_snapshot = image;
|
||||
_snapshotSize = fade.size;
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
||||
void _handleStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
setState(_drop);
|
||||
}
|
||||
}
|
||||
|
||||
void _drop() {
|
||||
_snapshot?.dispose();
|
||||
_snapshot = null;
|
||||
_snapshotSize = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_drop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ThemeCrossFadeLayer(
|
||||
key: _fadeKey,
|
||||
snapshot: _snapshot,
|
||||
snapshotSize: _snapshotSize,
|
||||
progress: _controller,
|
||||
child: RepaintBoundary(child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeCrossFadeLayer extends SingleChildRenderObjectWidget {
|
||||
const _ThemeCrossFadeLayer({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
required this.snapshotSize,
|
||||
required this.progress,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final ui.Image? snapshot;
|
||||
final Size? snapshotSize;
|
||||
final Animation<double> progress;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderThemeCrossFade(
|
||||
snapshot: snapshot,
|
||||
snapshotSize: snapshotSize,
|
||||
progress: progress,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderThemeCrossFade renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..snapshot = snapshot
|
||||
..snapshotSize = snapshotSize
|
||||
..progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderThemeCrossFade extends RenderProxyBox {
|
||||
_RenderThemeCrossFade({
|
||||
ui.Image? snapshot,
|
||||
Size? snapshotSize,
|
||||
required Animation<double> progress,
|
||||
}) : _snapshot = snapshot,
|
||||
_snapshotSize = snapshotSize,
|
||||
_progress = progress;
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
|
||||
ui.Image? _snapshot;
|
||||
|
||||
set snapshot(ui.Image? value) {
|
||||
if (identical(value, _snapshot)) {
|
||||
return;
|
||||
}
|
||||
_snapshot = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
Size? _snapshotSize;
|
||||
|
||||
set snapshotSize(Size? value) {
|
||||
_snapshotSize = value;
|
||||
}
|
||||
|
||||
Animation<double> _progress;
|
||||
|
||||
set progress(Animation<double> value) {
|
||||
if (identical(value, _progress)) {
|
||||
return;
|
||||
}
|
||||
if (attached) {
|
||||
_progress.removeListener(markNeedsPaint);
|
||||
value.addListener(markNeedsPaint);
|
||||
}
|
||||
_progress = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
/// The frame on screen, fade included.
|
||||
///
|
||||
/// The layer still holds it while the next frame builds; toImageSync
|
||||
/// asserts against a boundary that already needs paint.
|
||||
ui.Image? capture(double pixelRatio) {
|
||||
final layer = this.layer;
|
||||
if (!attached || !hasSize || layer is! OffsetLayer) {
|
||||
return null;
|
||||
}
|
||||
return layer.toImageSync(Offset.zero & size, pixelRatio: pixelRatio);
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_progress.addListener(markNeedsPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
_progress.removeListener(markNeedsPaint);
|
||||
super.detach();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
super.paint(context, offset);
|
||||
final image = _snapshot;
|
||||
final weight = 1 - _progress.value;
|
||||
if (image == null || weight <= 0 || _snapshotSize != size) {
|
||||
return;
|
||||
}
|
||||
final rect = offset & size;
|
||||
final fade = Color.fromRGBO(0, 0, 0, weight);
|
||||
// dstOut then plus mixes premultiplied pixels, so a translucent window
|
||||
// keeps its opacity through the fade where srcOver would raise it.
|
||||
context.canvas
|
||||
..drawRect(
|
||||
rect,
|
||||
Paint()
|
||||
..blendMode = BlendMode.dstOut
|
||||
..color = fade,
|
||||
)
|
||||
..drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
rect,
|
||||
Paint()
|
||||
..blendMode = BlendMode.plus
|
||||
..color = fade
|
||||
..filterQuality = FilterQuality.low,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,5 +68,6 @@ export 'text.dart';
|
||||
export 'text_loupe.dart';
|
||||
export 'text_style.dart';
|
||||
export 'theme.dart';
|
||||
export 'theme_cross_fade.dart';
|
||||
export 'typography.dart';
|
||||
export 'view_scope.dart';
|
||||
|
||||
+23
-25
@@ -39,23 +39,14 @@ class FontFamilyView extends ConsumerStatefulWidget {
|
||||
|
||||
class _FontFamilyViewState extends ConsumerState<FontFamilyView> {
|
||||
List<_InstalledFont>? _installed;
|
||||
var _loadStarted = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_loadStarted) {
|
||||
return;
|
||||
}
|
||||
_loadStarted = true;
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await whenRouteSettled(context);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final families = await loadSystemFontFamilies();
|
||||
if (mounted) {
|
||||
setState(() => _installed = families.map(_InstalledFont.new).toList());
|
||||
@@ -166,23 +157,30 @@ class _FontLabelState extends State<_FontLabel> {
|
||||
}
|
||||
_scheduled = true;
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
_scheduled = false;
|
||||
while (_waiting.isNotEmpty) {
|
||||
final next = _waiting.removeFirst();
|
||||
final family = next.widget.family;
|
||||
if (!next.mounted || next._isShown) {
|
||||
continue;
|
||||
}
|
||||
_shown.add(family);
|
||||
next.setState(() {});
|
||||
break;
|
||||
}
|
||||
_schedule();
|
||||
})
|
||||
..addPostFrameCallback(_showNext)
|
||||
..ensureVisualUpdate();
|
||||
}
|
||||
|
||||
static void _showNext(Duration _) {
|
||||
final next = _waiting.firstOrNull;
|
||||
if (next != null && next.mounted && isRouteMoving(next.context)) {
|
||||
// The frames of the motion carry the queue until it stops.
|
||||
SchedulerBinding.instance.addPostFrameCallback(_showNext);
|
||||
return;
|
||||
}
|
||||
_scheduled = false;
|
||||
while (_waiting.isNotEmpty) {
|
||||
final label = _waiting.removeFirst();
|
||||
if (!label.mounted || label._isShown) {
|
||||
continue;
|
||||
}
|
||||
_shown.add(label.widget.family);
|
||||
label.setState(() {});
|
||||
break;
|
||||
}
|
||||
_schedule();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isShown) {
|
||||
|
||||
+825
-726
File diff suppressed because it is too large
Load Diff
+306
-197
@@ -1,3 +1,4 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
@@ -182,7 +183,9 @@ class _ThemeLivePreviewState extends ConsumerState<ThemeLivePreview>
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
);
|
||||
final liquidGlass = ref.watch(
|
||||
themeSettingProvider.select((state) => state.liquidGlass),
|
||||
themeSettingProvider.select(
|
||||
(state) => state.liquidGlass != LiquidGlassMode.none,
|
||||
),
|
||||
);
|
||||
final corner = AppCorner.fit(_phoneWidth);
|
||||
return Container(
|
||||
@@ -227,7 +230,7 @@ class _ThemeLivePreviewState extends ConsumerState<ThemeLivePreview>
|
||||
}
|
||||
}
|
||||
|
||||
enum _MiniPage { cards, list }
|
||||
enum _MiniPage { dashboard, proxies, profiles, tools }
|
||||
|
||||
/// A phone-shaped sketch of the home page, drawn in [colorScheme].
|
||||
///
|
||||
@@ -261,11 +264,6 @@ class MiniScreen extends StatelessWidget {
|
||||
final TabAnimation tabAnimation;
|
||||
final ValueChanged<int>? onSelect;
|
||||
|
||||
bool get _hasFab {
|
||||
final previous = this.previous;
|
||||
return previous == null || progress > 0.5 ? selected == 0 : previous == 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sketch = SizedBox.expand(
|
||||
@@ -278,7 +276,6 @@ class MiniScreen extends StatelessWidget {
|
||||
previous: previous,
|
||||
progress: progress,
|
||||
tabAnimation: tabAnimation,
|
||||
hasFab: _hasFab,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -291,7 +288,6 @@ class MiniScreen extends StatelessWidget {
|
||||
final geometry = _MiniGeometry(
|
||||
constraints.biggest,
|
||||
floatingBar: floatingBar,
|
||||
hasFab: _hasFab,
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -355,44 +351,26 @@ class _DiagonalClipper extends CustomClipper<Path> {
|
||||
}
|
||||
|
||||
class _MiniGeometry {
|
||||
_MiniGeometry(this.size, {required bool floatingBar, required bool hasFab})
|
||||
_MiniGeometry(this.size, {required bool floatingBar})
|
||||
: unit = size.width / 20 {
|
||||
margin = unit * 1.5;
|
||||
fabSize = unit * 3.2;
|
||||
final barHeight = floatingBar ? unit * 3.2 : unit * 3.6;
|
||||
pageBottom = floatingBar ? 0 : barHeight;
|
||||
if (floatingBar) {
|
||||
final right = margin + (hasFab ? fabSize + unit * 0.6 : 0);
|
||||
bar = Rect.fromLTWH(
|
||||
margin,
|
||||
size.height - margin - barHeight,
|
||||
size.width - right - margin,
|
||||
barHeight,
|
||||
);
|
||||
fab = Rect.fromLTWH(
|
||||
size.width - margin - fabSize,
|
||||
size.height - margin - fabSize,
|
||||
fabSize,
|
||||
fabSize,
|
||||
);
|
||||
} else {
|
||||
bar = Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
|
||||
fab = Rect.fromLTWH(
|
||||
size.width - margin - fabSize * 1.6,
|
||||
size.height - (barHeight + margin) - fabSize,
|
||||
fabSize * 1.6,
|
||||
fabSize,
|
||||
);
|
||||
}
|
||||
bar = floatingBar
|
||||
? Rect.fromLTWH(
|
||||
margin,
|
||||
size.height - margin - barHeight,
|
||||
size.width - margin * 2,
|
||||
barHeight,
|
||||
)
|
||||
: Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
|
||||
}
|
||||
|
||||
final Size size;
|
||||
final double unit;
|
||||
late final double margin;
|
||||
late final double fabSize;
|
||||
late final double pageBottom;
|
||||
late final Rect bar;
|
||||
late final Rect fab;
|
||||
}
|
||||
|
||||
class _MiniScreenPainter extends CustomPainter {
|
||||
@@ -404,7 +382,6 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
required this.previous,
|
||||
required this.progress,
|
||||
required this.tabAnimation,
|
||||
required this.hasFab,
|
||||
});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
@@ -414,12 +391,11 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
final int? previous;
|
||||
final double progress;
|
||||
final TabAnimation tabAnimation;
|
||||
final bool hasFab;
|
||||
|
||||
bool get _glass => floatingBar && liquidGlass;
|
||||
|
||||
static _MiniPage _pageOf(int index) =>
|
||||
index.isEven ? _MiniPage.cards : _MiniPage.list;
|
||||
_MiniPage.values[index % _MiniPage.values.length];
|
||||
|
||||
static void _fill(
|
||||
Canvas canvas,
|
||||
@@ -433,28 +409,12 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
|
||||
}
|
||||
|
||||
static void _block(Canvas canvas, Rect rect, Color color, double extent) {
|
||||
_fill(canvas, rect, color, AppShape.all(AppCorner.fit(extent)));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final geometry = _MiniGeometry(
|
||||
size,
|
||||
floatingBar: floatingBar,
|
||||
hasFab: hasFab,
|
||||
);
|
||||
final geometry = _MiniGeometry(size, floatingBar: floatingBar);
|
||||
_paintPages(canvas, geometry);
|
||||
final unit = geometry.unit;
|
||||
final dock = SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme);
|
||||
if (hasFab && !floatingBar) {
|
||||
_block(
|
||||
canvas,
|
||||
geometry.fab,
|
||||
colorScheme.primaryContainer,
|
||||
geometry.fabSize,
|
||||
);
|
||||
}
|
||||
final shadow = BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: unit,
|
||||
@@ -468,9 +428,6 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
canvas.drawRect(geometry.bar, Paint()..color = dock);
|
||||
}
|
||||
_paintDestinations(canvas, geometry.bar, unit);
|
||||
if (hasFab && floatingBar) {
|
||||
_fill(canvas, geometry.fab, colorScheme.primaryContainer, AppShape.full);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintPages(Canvas canvas, _MiniGeometry geometry) {
|
||||
@@ -520,8 +477,13 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(bar.inflate(unit * 3))
|
||||
..addPath(path, Offset.zero),
|
||||
)
|
||||
..drawPath(path.shift(shadow.offset), shadow.toPaint())
|
||||
);
|
||||
AppShape.full.paintInterior(
|
||||
canvas,
|
||||
bar.shift(shadow.offset),
|
||||
shadow.toPaint(),
|
||||
);
|
||||
canvas
|
||||
..restore()
|
||||
..save()
|
||||
..clipPath(path)
|
||||
@@ -564,7 +526,6 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
return;
|
||||
}
|
||||
final size = geometry.size;
|
||||
final unit = geometry.unit;
|
||||
final margin = geometry.margin;
|
||||
final content = Rect.fromLTRB(
|
||||
offset + margin,
|
||||
@@ -578,152 +539,62 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
canvas.save();
|
||||
}
|
||||
canvas.clipRect(content, doAntiAlias: false);
|
||||
final left = content.left;
|
||||
final width = content.width;
|
||||
final card = SurfaceLevel.card.colorOf(colorScheme);
|
||||
final line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4);
|
||||
var top = content.top;
|
||||
_fill(
|
||||
_PageSketch(
|
||||
canvas,
|
||||
Rect.fromLTWH(left, top, unit * 7, unit * 1.1),
|
||||
colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
AppShape.full,
|
||||
);
|
||||
top += unit * 1.1 + unit * 0.5 + unit;
|
||||
switch (_pageOf(index)) {
|
||||
case _MiniPage.cards:
|
||||
_block(
|
||||
canvas,
|
||||
Rect.fromLTWH(left, top, width, unit * 5),
|
||||
card,
|
||||
unit * 5,
|
||||
);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
left + unit,
|
||||
top + (unit * 5 - unit * 2.2) / 2,
|
||||
unit * 2.2,
|
||||
unit * 2.2,
|
||||
),
|
||||
colorScheme.primary,
|
||||
AppShape.circle,
|
||||
);
|
||||
final lineLeft = left + unit + unit * 2.2 + unit;
|
||||
final lineTop = top + (unit * 5 - (unit + unit * 0.6 + unit * 0.8)) / 2;
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(lineLeft, lineTop, unit * 6, unit),
|
||||
line,
|
||||
AppShape.full,
|
||||
);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
lineLeft,
|
||||
lineTop + unit + unit * 0.6,
|
||||
unit * 3.5,
|
||||
unit * 0.8,
|
||||
),
|
||||
line,
|
||||
AppShape.full,
|
||||
);
|
||||
top += unit * 5 + unit;
|
||||
final half = (width - unit) / 2;
|
||||
_block(
|
||||
canvas,
|
||||
Rect.fromLTWH(left, top, half, unit * 4),
|
||||
card,
|
||||
unit * 4,
|
||||
);
|
||||
final chosen = Rect.fromLTWH(left + half + unit, top, half, unit * 4);
|
||||
_block(canvas, chosen, card, unit * 4);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromCircle(
|
||||
center: chosen.topRight + Offset(-unit * 1.3, unit * 1.3),
|
||||
radius: unit * 0.7,
|
||||
),
|
||||
colorScheme.primary,
|
||||
AppShape.circle,
|
||||
);
|
||||
top += unit * 4 + unit;
|
||||
for (var i = 0; i < 4; i++) {
|
||||
_block(
|
||||
canvas,
|
||||
Rect.fromLTWH(left, top, width, unit * 4.5),
|
||||
card,
|
||||
unit * 4.5,
|
||||
);
|
||||
top += unit * 4.5 + unit;
|
||||
}
|
||||
case _MiniPage.list:
|
||||
final lineLeft = left + unit * 2.2 + unit;
|
||||
for (var i = 0; i < 9; i++) {
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
left,
|
||||
top + (unit * 2.6 - unit * 2.2) / 2,
|
||||
unit * 2.2,
|
||||
unit * 2.2,
|
||||
),
|
||||
SurfaceLevel.inset.colorOf(colorScheme),
|
||||
AppShape.circle,
|
||||
);
|
||||
final lineTop =
|
||||
top + (unit * 2.6 - (unit * 0.8 + unit * 0.5 + unit * 0.6)) / 2;
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
lineLeft,
|
||||
lineTop,
|
||||
unit * (8 - (i + index) % 3 * 1.5),
|
||||
unit * 0.8,
|
||||
),
|
||||
line,
|
||||
AppShape.full,
|
||||
);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromLTWH(
|
||||
lineLeft,
|
||||
lineTop + unit * 0.8 + unit * 0.5,
|
||||
unit * 4,
|
||||
unit * 0.6,
|
||||
),
|
||||
line.withValues(alpha: 0.2),
|
||||
AppShape.full,
|
||||
);
|
||||
top += unit * 2.6 + unit;
|
||||
}
|
||||
}
|
||||
colorScheme,
|
||||
geometry.unit,
|
||||
content,
|
||||
).paint(_pageOf(index));
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintDestinations(Canvas canvas, Rect bar, double unit) {
|
||||
final cell = bar.width / MiniScreen.destinationCount;
|
||||
final dark = colorScheme.brightness == Brightness.dark;
|
||||
final indicator = _glass
|
||||
? colorScheme.onSurface.withValues(alpha: dark ? 0.14 : 0.08)
|
||||
: SurfaceLevel.floatingOf(colorScheme).next.colorOf(colorScheme);
|
||||
for (var i = 0; i < MiniScreen.destinationCount; i++) {
|
||||
final center = Offset(bar.left + cell * i + cell / 2, bar.center.dy);
|
||||
final dot = Rect.fromCenter(
|
||||
center: center,
|
||||
width: unit * 0.9,
|
||||
height: unit * 0.9,
|
||||
);
|
||||
if (i != selected) {
|
||||
_fill(canvas, dot, colorScheme.onSurfaceVariant, AppShape.circle);
|
||||
continue;
|
||||
final icon = center.translate(0, -unit * 0.4);
|
||||
final isSelected = i == selected;
|
||||
if (isSelected) {
|
||||
_fill(
|
||||
canvas,
|
||||
floatingBar
|
||||
? Rect.fromCenter(
|
||||
center: center,
|
||||
width: cell - unit * 0.5,
|
||||
height: bar.height - unit * 0.5,
|
||||
)
|
||||
: Rect.fromCenter(
|
||||
center: icon,
|
||||
width: unit * 2.8,
|
||||
height: unit * 1.5,
|
||||
),
|
||||
indicator,
|
||||
AppShape.full,
|
||||
);
|
||||
}
|
||||
final dark = colorScheme.brightness == Brightness.dark;
|
||||
final ink = isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant;
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromCenter(center: center, width: unit * 2.8, height: unit * 1.6),
|
||||
_glass
|
||||
? colorScheme.onSurface.withValues(alpha: dark ? 0.14 : 0.08)
|
||||
: SurfaceLevel.floatingOf(colorScheme).next.colorOf(colorScheme),
|
||||
Rect.fromCircle(center: icon, radius: unit * 0.45),
|
||||
ink,
|
||||
AppShape.circle,
|
||||
);
|
||||
_fill(
|
||||
canvas,
|
||||
Rect.fromCenter(
|
||||
center: center.translate(0, unit * 0.8),
|
||||
width: unit * 1.7,
|
||||
height: unit * 0.35,
|
||||
),
|
||||
isSelected ? ink : ink.withValues(alpha: 0.6),
|
||||
AppShape.full,
|
||||
);
|
||||
_fill(canvas, dot, colorScheme.primary, AppShape.circle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +606,245 @@ class _MiniScreenPainter extends CustomPainter {
|
||||
oldDelegate.selected != selected ||
|
||||
oldDelegate.previous != previous ||
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.tabAnimation != tabAnimation ||
|
||||
oldDelegate.hasFab != hasFab;
|
||||
oldDelegate.tabAnimation != tabAnimation;
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of a [MiniScreen] after the app's own, laid out in units of a
|
||||
/// twentieth of the screen's width from the top left of [content].
|
||||
class _PageSketch {
|
||||
_PageSketch(this.canvas, this.colorScheme, this.unit, this.content)
|
||||
: width = content.width / unit,
|
||||
card = SurfaceLevel.card.colorOf(colorScheme),
|
||||
inset = SurfaceLevel.inset.colorOf(colorScheme),
|
||||
text = colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
faint = colorScheme.onSurfaceVariant.withValues(alpha: 0.16);
|
||||
|
||||
static const double _gap = 0.6;
|
||||
|
||||
final Canvas canvas;
|
||||
final ColorScheme colorScheme;
|
||||
final double unit;
|
||||
final Rect content;
|
||||
final double width;
|
||||
final Color card;
|
||||
final Color inset;
|
||||
final Color text;
|
||||
final Color line;
|
||||
final Color faint;
|
||||
|
||||
void paint(_MiniPage page) {
|
||||
switch (page) {
|
||||
case _MiniPage.dashboard:
|
||||
_dashboard(_header(actions: 1));
|
||||
case _MiniPage.proxies:
|
||||
_proxies(_header(actions: 2));
|
||||
case _MiniPage.profiles:
|
||||
_profiles(_header(actions: 2));
|
||||
case _MiniPage.tools:
|
||||
_tools(_header(actions: 0));
|
||||
}
|
||||
}
|
||||
|
||||
Rect _rect(double x, double y, double w, double h) => Rect.fromLTWH(
|
||||
content.left + x * unit,
|
||||
content.top + y * unit,
|
||||
w * unit,
|
||||
h * unit,
|
||||
);
|
||||
|
||||
ShapeBorder _shape(double extent) =>
|
||||
AppShape.all(AppCorner.fit(unit * extent));
|
||||
|
||||
void _block(Rect rect, Color color, {double extent = 5}) {
|
||||
_MiniScreenPainter._fill(canvas, rect, color, _shape(extent));
|
||||
}
|
||||
|
||||
void _pill(double x, double y, double w, double h, Color color) {
|
||||
_MiniScreenPainter._fill(canvas, _rect(x, y, w, h), color, AppShape.full);
|
||||
}
|
||||
|
||||
void _dot(double x, double y, double radius, Color color) {
|
||||
_MiniScreenPainter._fill(
|
||||
canvas,
|
||||
_rect(x - radius, y - radius, radius * 2, radius * 2),
|
||||
color,
|
||||
AppShape.circle,
|
||||
);
|
||||
}
|
||||
|
||||
/// The title and the bar's round buttons; returns where the body starts.
|
||||
double _header({required int actions}) {
|
||||
_pill(0, 0.45, 7, 1.1, text);
|
||||
for (var i = 0; i < actions; i++) {
|
||||
final x = width - 1 - i * 2.4;
|
||||
_dot(x, 1, 1, card);
|
||||
_dot(x, 1, 0.3, line);
|
||||
}
|
||||
return 2.8;
|
||||
}
|
||||
|
||||
void _dashboard(double top) {
|
||||
var y = top;
|
||||
final status = _rect(0, y, width, 6.2);
|
||||
final buttonX = width - 3;
|
||||
final buttonY = y + 3.1;
|
||||
_block(status, card);
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(_shape(5).getOuterPath(status))
|
||||
..drawCircle(
|
||||
_rect(buttonX, buttonY, 0, 0).topLeft,
|
||||
unit * 4.6,
|
||||
Paint()..color = colorScheme.primary.withValues(alpha: 0.1),
|
||||
)
|
||||
..restore();
|
||||
_dot(buttonX, buttonY, 1.8, colorScheme.primary);
|
||||
for (final dx in [-0.675, 0.325]) {
|
||||
_pill(buttonX + dx, buttonY - 0.6, 0.35, 1.2, colorScheme.onPrimary);
|
||||
}
|
||||
_pill(1, y + 0.9, 3, 0.7, line);
|
||||
_dot(1.9, y + 3, 0.9, inset);
|
||||
_pill(3.5, y + 2.3, 5, 0.9, text);
|
||||
_pill(3.5, y + 3.5, 2.4, 0.7, inset);
|
||||
_pill(1, y + 4.8, 2.4, 0.7, line);
|
||||
_pill(4.6, y + 4.8, 3.6, 0.7, line);
|
||||
y += 6.2 + _gap;
|
||||
|
||||
_block(_rect(0, y, width, 5.8), card);
|
||||
_pill(1, y + 0.9, 4.5, 0.7, line);
|
||||
final tileWidth = (width - 2.6) / 2;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final x = 1 + i * (tileWidth + 0.6);
|
||||
final tile = _rect(x, y + 2.1, tileWidth, 2.9);
|
||||
_block(tile, inset, extent: 2.9);
|
||||
_pill(x + 0.6, y + 2.65, 2.4, 0.7, line);
|
||||
_speedGraph(
|
||||
tile,
|
||||
phase: i * 1.7,
|
||||
color: i == 0
|
||||
? colorScheme.primary.withValues(alpha: 0.5)
|
||||
: colorScheme.primary,
|
||||
);
|
||||
}
|
||||
y += 5.8 + _gap;
|
||||
|
||||
_block(_rect(0, y, width, 4.4), card);
|
||||
_pill(1, y + 0.9, 4, 0.7, line);
|
||||
final segment = (width - 2.6) / 3;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final x = 1 + i * (segment + 0.3);
|
||||
_pill(x, y + 2.1, segment, 1.6, i == 0 ? colorScheme.primary : inset);
|
||||
_dot(
|
||||
x + segment / 2,
|
||||
y + 2.9,
|
||||
0.3,
|
||||
i == 0 ? colorScheme.onPrimary : line,
|
||||
);
|
||||
}
|
||||
y += 4.4 + _gap;
|
||||
|
||||
_block(_rect(0, y, width, 3), card);
|
||||
_dot(1.9, y + 1.5, 0.9, colorScheme.primary);
|
||||
_pill(3.4, y + 1.1, 2.4, 0.8, line);
|
||||
_pill(width - 3.6, y + 0.8, 2.6, 1.4, colorScheme.primary);
|
||||
_dot(width - 1.7, y + 1.5, 0.55, colorScheme.onPrimary);
|
||||
y += 3 + _gap;
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
_block(_rect(0, y, width, 4.5), card);
|
||||
_pill(1, y + 0.9, 3.5 + i % 2, 0.7, line);
|
||||
y += 4.5 + _gap;
|
||||
}
|
||||
}
|
||||
|
||||
void _speedGraph(Rect tile, {required double phase, required Color color}) {
|
||||
const steps = 16;
|
||||
final path = Path();
|
||||
for (var i = 0; i <= steps; i++) {
|
||||
final x = tile.left + tile.width * i / steps;
|
||||
final y =
|
||||
tile.top +
|
||||
tile.height * 0.68 +
|
||||
math.sin(i * 0.9 + phase) * unit * 0.35;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
final area = Path.from(path)
|
||||
..lineTo(tile.right, tile.bottom)
|
||||
..lineTo(tile.left, tile.bottom)
|
||||
..close();
|
||||
canvas
|
||||
..save()
|
||||
..clipPath(_shape(2.9).getOuterPath(tile))
|
||||
..drawPath(area, Paint()..color = color.withValues(alpha: color.a * 0.2))
|
||||
..drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = unit * 0.15
|
||||
..color = color,
|
||||
)
|
||||
..restore();
|
||||
}
|
||||
|
||||
void _proxies(double top) {
|
||||
var y = top;
|
||||
for (var i = 0; i < 9; i++) {
|
||||
_block(_rect(0, y, width, 2.6), card);
|
||||
_dot(1.5, y + 1.3, 0.8, inset);
|
||||
_pill(3, y + 0.6, 3 + i % 3, 0.7, line);
|
||||
_pill(3, y + 1.55, 4, 0.5, faint);
|
||||
_pill(width - 4.4, y + 1.05, 1.8, 0.5, faint);
|
||||
_dot(width - 1.3, y + 1.3, 0.55, inset);
|
||||
y += 2.6 + _gap;
|
||||
}
|
||||
}
|
||||
|
||||
void _profiles(double top) {
|
||||
var y = top;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
_block(_rect(0, y, width, 2.8), card);
|
||||
_pill(1, y + 0.7, 3.4 - i * 0.6, 0.75, line);
|
||||
_pill(
|
||||
4.8 - i * 0.6,
|
||||
y + 0.72,
|
||||
2.6,
|
||||
0.7,
|
||||
colorScheme.primary.withValues(alpha: 0.16),
|
||||
);
|
||||
_pill(1, y + 1.7, 3, 0.5, faint);
|
||||
_pill(width - 4.1, y + 1.15, 1.6, 0.5, faint);
|
||||
if (i == 0) {
|
||||
_dot(width - 1.4, y + 1.4, 0.6, colorScheme.primary);
|
||||
}
|
||||
y += 2.8 + _gap;
|
||||
}
|
||||
}
|
||||
|
||||
void _tools(double top) {
|
||||
const rowHeight = 2.4;
|
||||
var y = top;
|
||||
for (final rows in [4, 7]) {
|
||||
_pill(0.8, y + 0.2, 1.6 + rows * 0.15, 0.5, faint);
|
||||
y += 1.3;
|
||||
_block(_rect(0, y, width, rows * rowHeight), card);
|
||||
for (var i = 0; i < rows; i++) {
|
||||
final row = y + i * rowHeight;
|
||||
_dot(1.3, row + rowHeight / 2, 0.45, line);
|
||||
_pill(2.6, row + 0.85, 4 + (i * 3 + rows) % 4, 0.7, line);
|
||||
if (i < rows - 1) {
|
||||
canvas.drawRect(
|
||||
_rect(2.6, row + rowHeight - 0.03, width - 3.4, 0.06),
|
||||
Paint()..color = faint,
|
||||
);
|
||||
}
|
||||
}
|
||||
y += rows * rowHeight + _gap * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/ui/card.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/ui/shape.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class ColorSchemeBox extends StatelessWidget {
|
||||
final Color? primaryColor;
|
||||
final bool? isSelected;
|
||||
final void Function()? onPressed;
|
||||
final double size;
|
||||
|
||||
const ColorSchemeBox({
|
||||
super.key,
|
||||
required this.primaryColor,
|
||||
this.onPressed,
|
||||
this.isSelected,
|
||||
this.size = 48,
|
||||
});
|
||||
|
||||
static const _duration = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = isSelected ?? false;
|
||||
final ring = Theme.of(context).colorScheme.primary;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: PrimaryColorBox(
|
||||
primaryColor: primaryColor,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
shape: AppShape.circle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: AppShape.circle,
|
||||
onTap: onPressed,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: _duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.all(selected ? 5 : 0),
|
||||
decoration: ShapeDecoration(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(
|
||||
color: selected ? ring : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(color: colorScheme.primary),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedScale(
|
||||
duration: _duration,
|
||||
curve: Curves.easeOutBack,
|
||||
scale: selected ? 1 : 0,
|
||||
child: const SelectIcon(),
|
||||
),
|
||||
if (primaryColor == null)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: AppShape.circle,
|
||||
),
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.eyedropper,
|
||||
size: size / 4,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PrimaryColorBox extends ConsumerWidget {
|
||||
final Color? primaryColor;
|
||||
final Widget child;
|
||||
|
||||
const PrimaryColorBox({
|
||||
super.key,
|
||||
required this.primaryColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final themeData = Theme.of(context);
|
||||
final colorScheme = ref.watch(
|
||||
genColorSchemeProvider(
|
||||
themeData.brightness,
|
||||
color: primaryColor,
|
||||
ignoreConfig: true,
|
||||
),
|
||||
);
|
||||
return Theme(
|
||||
data: themeData.copyWith(colorScheme: colorScheme),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+539
-263
@@ -1,17 +1,22 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/ui/ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
|
||||
import 'color_scheme_box.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _trackHeight = 20.0;
|
||||
const _thumbRadius = 14.0;
|
||||
const _trackTone = 60.0;
|
||||
const _previewInset = 8.0;
|
||||
const _thumbRingWidth = 3.0;
|
||||
|
||||
// The corner follows the grid's cells, which a larger one would cut away.
|
||||
const _padCorner = AppCorner.sm;
|
||||
|
||||
enum _PaletteMode { grid, spectrum, sliders }
|
||||
|
||||
/// Picks a color as iOS's color picker does: from a grid, a spectrum of hue
|
||||
/// and lightness, or hue, saturation and brightness sliders.
|
||||
class Palette extends StatefulWidget {
|
||||
const Palette({super.key, required this.controller});
|
||||
|
||||
@@ -22,98 +27,349 @@ class Palette extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PaletteState extends State<Palette> {
|
||||
double _hue = 0;
|
||||
double _chroma = 0;
|
||||
double _tone = 0;
|
||||
var _mode = _PaletteMode.grid;
|
||||
late HSVColor _hsv = HSVColor.fromColor(widget.controller.value);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFromColor(widget.controller.value);
|
||||
widget.controller.addListener(_syncHsv);
|
||||
}
|
||||
|
||||
void _initFromColor(Color color) {
|
||||
final hct = Hct.fromInt(color.toARGB32());
|
||||
_hue = hct.hue;
|
||||
_chroma = hct.chroma;
|
||||
_tone = hct.tone;
|
||||
@override
|
||||
void didUpdateWidget(Palette oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_syncHsv);
|
||||
widget.controller.addListener(_syncHsv);
|
||||
_syncHsv();
|
||||
}
|
||||
}
|
||||
|
||||
Color _toColor() => Color(Hct.from(_hue, _chroma, _tone).toInt());
|
||||
|
||||
void _onHueChanged(double value) {
|
||||
setState(() => _hue = value);
|
||||
widget.controller.value = _toColor();
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_syncHsv);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChromaChanged(double value) {
|
||||
setState(() => _chroma = value);
|
||||
widget.controller.value = _toColor();
|
||||
// A gray drops its hue and black its saturation; the sliders keep the ones
|
||||
// they were dragged to until the color comes from elsewhere.
|
||||
void _syncHsv() {
|
||||
if (_hsv.toColor() != widget.controller.value) {
|
||||
setState(() => _hsv = HSVColor.fromColor(widget.controller.value));
|
||||
}
|
||||
}
|
||||
|
||||
void _onToneSelected(double tone) {
|
||||
setState(() => _tone = tone);
|
||||
widget.controller.value = _toColor();
|
||||
void _pick(Color color) => widget.controller.value = color;
|
||||
|
||||
void _pickHsv(HSVColor hsv) {
|
||||
setState(() => _hsv = hsv);
|
||||
widget.controller.value = hsv.toColor();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final maxChroma = Hct.from(_hue, 200, _trackTone).chroma;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.controller,
|
||||
builder: (_, _, _) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_GradientSlider(
|
||||
value: _hue,
|
||||
max: 360,
|
||||
thumbColor: Color(Hct.from(_hue, 100, _trackTone).toInt()),
|
||||
colors: [
|
||||
for (var hue = 0; hue <= 360; hue += 10)
|
||||
Color(Hct.from(hue.toDouble(), 100, _trackTone).toInt()),
|
||||
],
|
||||
onChanged: _onHueChanged,
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedControl<_PaletteMode>(
|
||||
segments: [
|
||||
(_PaletteMode.grid, appLocalizations.grid),
|
||||
(_PaletteMode.spectrum, appLocalizations.spectrum),
|
||||
(_PaletteMode.sliders, appLocalizations.sliders),
|
||||
],
|
||||
value: _mode,
|
||||
onChanged: (mode) => setState(() => _mode = mode),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: widget.controller,
|
||||
builder: (_, color, _) => switch (_mode) {
|
||||
_PaletteMode.grid => _ColorGrid(color: color, onPicked: _pick),
|
||||
_PaletteMode.spectrum => _Spectrum(color: color, onPicked: _pick),
|
||||
_PaletteMode.sliders => _HsvSliders(
|
||||
hsv: _hsv,
|
||||
onChanged: _pickHsv,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_GradientSlider(
|
||||
value: _chroma.clamp(0, maxChroma),
|
||||
max: maxChroma,
|
||||
thumbColor: Color(Hct.from(_hue, _chroma, _trackTone).toInt()),
|
||||
colors: [
|
||||
for (var i = 0; i <= 24; i++)
|
||||
Color(
|
||||
Hct.from(_hue, maxChroma * i / 24, _trackTone).toInt(),
|
||||
),
|
||||
],
|
||||
onChanged: _onChromaChanged,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_ToneStrip(
|
||||
hue: _hue,
|
||||
chroma: _chroma,
|
||||
selectedTone: _tone,
|
||||
onToneSelected: _onToneSelected,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
context.appLocalizations.preview,
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
PrimaryColorBox(
|
||||
primaryColor: widget.controller.value,
|
||||
child: const _ColorSchemePreview(),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorGrid extends StatelessWidget {
|
||||
const _ColorGrid({required this.color, required this.onPicked});
|
||||
|
||||
// iOS's columns run from sky blue round through red to green, skipping the
|
||||
// teals between green and sky.
|
||||
static const _hues = [
|
||||
230.0,
|
||||
262.0,
|
||||
285.0,
|
||||
310.0,
|
||||
340.0,
|
||||
20.0,
|
||||
40.0,
|
||||
60.0,
|
||||
80.0,
|
||||
100.0,
|
||||
120.0,
|
||||
145.0,
|
||||
];
|
||||
static const _tones = [25.0, 33.0, 41.0, 49.0, 57.0, 65.0, 73.0, 81.0, 89.0];
|
||||
|
||||
static final List<List<Color>> _rows = [
|
||||
[
|
||||
for (var column = 0; column < _hues.length; column++)
|
||||
Color(Hct.from(0, 0, 100 - column * 100 / (_hues.length - 1)).toInt()),
|
||||
],
|
||||
for (final tone in _tones)
|
||||
[for (final hue in _hues) Color(Hct.from(hue, 200, tone).toInt())],
|
||||
];
|
||||
|
||||
final Color color;
|
||||
final ValueChanged<Color> onPicked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final columns = _hues.length;
|
||||
final rows = _rows.length;
|
||||
return AspectRatio(
|
||||
aspectRatio: columns / rows,
|
||||
child: _PickArea(
|
||||
onPicked: (position, size) {
|
||||
final column = (position.dx / size.width * columns).floor();
|
||||
final row = (position.dy / size.height * rows).floor();
|
||||
onPicked(_rows[row.clamp(0, rows - 1)][column.clamp(0, columns - 1)]);
|
||||
},
|
||||
child: CustomPaint(
|
||||
painter: _GridPainter(rows: _rows, selected: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPainter extends CustomPainter {
|
||||
const _GridPainter({required this.rows, required this.selected});
|
||||
|
||||
final List<List<Color>> rows;
|
||||
final Color selected;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final cellWidth = size.width / rows.first.length;
|
||||
final cellHeight = size.height / rows.length;
|
||||
canvas.save();
|
||||
canvas.clipRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(_padCorner),
|
||||
),
|
||||
);
|
||||
// Without anti-aliasing the cells meet on whole pixels, with no seam of
|
||||
// the page between them.
|
||||
final paint = Paint()..isAntiAlias = false;
|
||||
Rect? selectedCell;
|
||||
for (var row = 0; row < rows.length; row++) {
|
||||
for (var column = 0; column < rows[row].length; column++) {
|
||||
final cell = Rect.fromLTWH(
|
||||
column * cellWidth,
|
||||
row * cellHeight,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
);
|
||||
final color = rows[row][column];
|
||||
canvas.drawRect(cell, paint..color = color);
|
||||
if (color == selected) {
|
||||
selectedCell = cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectedCell != null) {
|
||||
final light =
|
||||
ThemeData.estimateBrightnessForColor(selected) == Brightness.dark;
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(
|
||||
selectedCell.deflate(_thumbRingWidth / 2),
|
||||
const Radius.circular(AppCorner.xs),
|
||||
),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = _thumbRingWidth
|
||||
..color = light ? Colors.white : Colors.black,
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GridPainter oldDelegate) =>
|
||||
oldDelegate.selected != selected || oldDelegate.rows != rows;
|
||||
}
|
||||
|
||||
class _Spectrum extends StatefulWidget {
|
||||
const _Spectrum({required this.color, required this.onPicked});
|
||||
|
||||
final Color color;
|
||||
final ValueChanged<Color> onPicked;
|
||||
|
||||
@override
|
||||
State<_Spectrum> createState() => _SpectrumState();
|
||||
}
|
||||
|
||||
class _SpectrumState extends State<_Spectrum> {
|
||||
// White and black carry no hue, so the thumb stays where it was dragged
|
||||
// rather than jumping to the red end.
|
||||
({Offset position, Color color})? _picked;
|
||||
|
||||
Offset _positionOf(Color color) {
|
||||
final picked = _picked;
|
||||
if (picked != null && picked.color == color) {
|
||||
return picked.position;
|
||||
}
|
||||
final hsl = HSLColor.fromColor(color);
|
||||
return Offset(hsl.hue / 360, 1 - hsl.lightness);
|
||||
}
|
||||
|
||||
void _handlePicked(Offset position, Size size) {
|
||||
final fraction = Offset(
|
||||
position.dx / size.width,
|
||||
position.dy / size.height,
|
||||
);
|
||||
final color = HSLColor.fromAHSL(
|
||||
1,
|
||||
clampDouble(fraction.dx * 360, 0, 359.99),
|
||||
1,
|
||||
1 - fraction.dy,
|
||||
).toColor();
|
||||
_picked = (position: fraction, color: color);
|
||||
widget.onPicked(color);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 6 / 5,
|
||||
child: _PickArea(
|
||||
onPicked: _handlePicked,
|
||||
child: CustomPaint(
|
||||
painter: _SpectrumPainter(
|
||||
color: widget.color,
|
||||
position: _positionOf(widget.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpectrumPainter extends CustomPainter {
|
||||
const _SpectrumPainter({required this.color, required this.position});
|
||||
|
||||
static final _hues = [
|
||||
for (var hue = 0; hue <= 360; hue += 60)
|
||||
HSLColor.fromAHSL(1, hue % 360, 1, 0.5).toColor(),
|
||||
];
|
||||
|
||||
final Color color;
|
||||
final Offset position;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
final shape = RSuperellipse.fromRectAndRadius(
|
||||
rect,
|
||||
const Radius.circular(_padCorner),
|
||||
);
|
||||
canvas.drawRSuperellipse(
|
||||
shape,
|
||||
Paint()..shader = LinearGradient(colors: _hues).createShader(rect),
|
||||
);
|
||||
// Mixing the pure hue with white above the middle and black below is
|
||||
// exactly HSL lightness at full saturation, which picking reads back.
|
||||
canvas.drawRSuperellipse(
|
||||
shape,
|
||||
Paint()
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white,
|
||||
Color(0x00FFFFFF),
|
||||
Color(0x00000000),
|
||||
Colors.black,
|
||||
],
|
||||
stops: [0, 0.5, 0.5, 1],
|
||||
).createShader(rect),
|
||||
);
|
||||
_paintColorThumb(
|
||||
canvas,
|
||||
Offset(position.dx * size.width, position.dy * size.height),
|
||||
_thumbRadius,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SpectrumPainter oldDelegate) =>
|
||||
oldDelegate.color != color || oldDelegate.position != position;
|
||||
}
|
||||
|
||||
/// Claims the pointer on contact, so a drag that starts on the pad picks
|
||||
/// colors instead of moving the sheet or the list around it.
|
||||
class _ImmediatePanGestureRecognizer extends PanGestureRecognizer {
|
||||
_ImmediatePanGestureRecognizer({super.debugOwner});
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
super.addAllowedPointer(event);
|
||||
resolve(GestureDisposition.accepted);
|
||||
}
|
||||
}
|
||||
|
||||
class _PickArea extends StatelessWidget {
|
||||
const _PickArea({required this.onPicked, required this.child});
|
||||
|
||||
final void Function(Offset position, Size size) onPicked;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final size = constraints.biggest;
|
||||
void pick(Offset position) => onPicked(
|
||||
Offset(
|
||||
clampDouble(position.dx, 0, size.width),
|
||||
clampDouble(position.dy, 0, size.height),
|
||||
),
|
||||
size,
|
||||
);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: RawGestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
gestures: {
|
||||
_ImmediatePanGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<
|
||||
_ImmediatePanGestureRecognizer
|
||||
>(
|
||||
() => _ImmediatePanGestureRecognizer(debugOwner: this),
|
||||
(recognizer) => recognizer
|
||||
..onDown = ((details) => pick(details.localPosition))
|
||||
..onUpdate = ((details) => pick(details.localPosition)),
|
||||
),
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -121,6 +377,124 @@ class _PaletteState extends State<Palette> {
|
||||
}
|
||||
}
|
||||
|
||||
class _HsvSliders extends StatelessWidget {
|
||||
const _HsvSliders({required this.hsv, required this.onChanged});
|
||||
|
||||
static final _hues = [
|
||||
for (var hue = 0; hue <= 360; hue += 60)
|
||||
HSVColor.fromAHSV(1, hue % 360, 1, 1).toColor(),
|
||||
];
|
||||
|
||||
final HSVColor hsv;
|
||||
final ValueChanged<HSVColor> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final colorScheme = context.colorScheme;
|
||||
final color = hsv.toColor();
|
||||
final level = context.raisedSurfaceLevel;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
|
||||
decoration: ShapeDecoration(
|
||||
color: level.colorOf(colorScheme),
|
||||
shape: AppShape.xl,
|
||||
),
|
||||
child: SurfaceScope(
|
||||
level: level,
|
||||
child: Column(
|
||||
spacing: 20,
|
||||
children: [
|
||||
_LabeledSlider(
|
||||
label: appLocalizations.hue,
|
||||
value: '${hsv.hue.round()}°',
|
||||
slider: _GradientSlider(
|
||||
value: hsv.hue,
|
||||
max: 360,
|
||||
thumbColor: hsv.withSaturation(1).withValue(1).toColor(),
|
||||
colors: _hues,
|
||||
onChanged: (value) => onChanged(hsv.withHue(value)),
|
||||
),
|
||||
),
|
||||
_LabeledSlider(
|
||||
label: appLocalizations.saturation,
|
||||
value: '${(hsv.saturation * 100).round()}%',
|
||||
slider: _GradientSlider(
|
||||
value: hsv.saturation,
|
||||
max: 1,
|
||||
thumbColor: color,
|
||||
colors: [
|
||||
hsv.withSaturation(0).toColor(),
|
||||
hsv.withSaturation(1).toColor(),
|
||||
],
|
||||
onChanged: (value) => onChanged(hsv.withSaturation(value)),
|
||||
),
|
||||
),
|
||||
_LabeledSlider(
|
||||
label: appLocalizations.brightness,
|
||||
value: '${(hsv.value * 100).round()}%',
|
||||
slider: _GradientSlider(
|
||||
value: hsv.value,
|
||||
max: 1,
|
||||
thumbColor: color,
|
||||
colors: [
|
||||
hsv.withValue(0).toColor(),
|
||||
hsv.withValue(1).toColor(),
|
||||
],
|
||||
onChanged: (value) => onChanged(hsv.withValue(value)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabeledSlider extends StatelessWidget {
|
||||
const _LabeledSlider({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.slider,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Widget slider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = AppTextStyle.subheadline
|
||||
.of(context)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTextStyle.subheadline
|
||||
.of(context, emphasized: true)
|
||||
.copyWith(color: context.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: style.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
slider,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientSlider extends StatelessWidget {
|
||||
const _GradientSlider({
|
||||
required this.value,
|
||||
@@ -231,8 +605,6 @@ class _ColorThumbShape extends SliderComponentShape {
|
||||
|
||||
final Color color;
|
||||
|
||||
static const _ringWidth = 3.0;
|
||||
|
||||
@override
|
||||
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
|
||||
const Size.fromRadius(_thumbRadius);
|
||||
@@ -252,201 +624,105 @@ class _ColorThumbShape extends SliderComponentShape {
|
||||
required double textScaleFactor,
|
||||
required Size sizeWithOverflow,
|
||||
}) {
|
||||
final canvas = context.canvas;
|
||||
final radius = _thumbRadius + activationAnimation.value * 2;
|
||||
canvas.drawCircle(
|
||||
center.translate(0, 1),
|
||||
radius,
|
||||
Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.28)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.5),
|
||||
);
|
||||
canvas.drawCircle(center, radius, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(center, radius - _ringWidth, Paint()..color = color);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToneStrip extends StatelessWidget {
|
||||
const _ToneStrip({
|
||||
required this.hue,
|
||||
required this.chroma,
|
||||
required this.selectedTone,
|
||||
required this.onToneSelected,
|
||||
});
|
||||
|
||||
final double hue;
|
||||
final double chroma;
|
||||
final double selectedTone;
|
||||
final ValueChanged<double> onToneSelected;
|
||||
|
||||
static const _tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 44,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: const ShapeDecoration(shape: AppShape.md),
|
||||
foregroundDecoration: ShapeDecoration(
|
||||
shape: AppShape.md.copyWith(
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final tone in _tones)
|
||||
Expanded(
|
||||
child: _ToneCell(
|
||||
tone: tone,
|
||||
color: Color(Hct.from(hue, chroma, tone.toDouble()).toInt()),
|
||||
isSelected: tone == selectedTone.round(),
|
||||
onSelected: () => onToneSelected(tone.toDouble()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_paintColorThumb(
|
||||
context.canvas,
|
||||
center,
|
||||
_thumbRadius + activationAnimation.value * 2,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToneCell extends StatefulWidget {
|
||||
const _ToneCell({
|
||||
required this.tone,
|
||||
void _paintColorThumb(
|
||||
Canvas canvas,
|
||||
Offset center,
|
||||
double radius,
|
||||
Color color,
|
||||
) {
|
||||
canvas.drawCircle(
|
||||
center.translate(0, 1),
|
||||
radius,
|
||||
Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.28)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.5),
|
||||
);
|
||||
canvas.drawCircle(center, radius, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(center, radius - _thumbRingWidth, Paint()..color = color);
|
||||
}
|
||||
|
||||
/// The six hex digits of an sRGB color, kept in step with [color] while
|
||||
/// the reader is not halfway through typing one.
|
||||
class HexColorField extends StatefulWidget {
|
||||
const HexColorField({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final int tone;
|
||||
final Color color;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
final ValueChanged<Color> onChanged;
|
||||
|
||||
@override
|
||||
State<_ToneCell> createState() => _ToneCellState();
|
||||
State<HexColorField> createState() => _HexColorFieldState();
|
||||
}
|
||||
|
||||
class _ToneCellState extends State<_ToneCell> {
|
||||
bool _isFocused = false;
|
||||
class _HexColorFieldState extends State<HexColorField> {
|
||||
static const _length = 6;
|
||||
|
||||
late final _controller = TextEditingController(text: _digitsOf(widget.color));
|
||||
|
||||
static String _digitsOf(Color color) => color.hex.substring(1);
|
||||
|
||||
static Color? _parse(String text) => text.length == _length
|
||||
? Color(0xFF000000 | int.parse(text, radix: 16))
|
||||
: null;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(HexColorField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.color != oldWidget.color &&
|
||||
_parse(_controller.text) != widget.color) {
|
||||
final digits = _digitsOf(widget.color);
|
||||
_controller.value = TextEditingValue(
|
||||
text: digits,
|
||||
selection: TextSelection.collapsed(offset: digits.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foregroundColor = widget.tone <= 50 ? Colors.white : Colors.black;
|
||||
final showRing = widget.isSelected || _isFocused;
|
||||
return Material(
|
||||
color: widget.color,
|
||||
child: InkWell(
|
||||
onTap: widget.onSelected,
|
||||
onFocusChange: (value) => setState(() => _isFocused = value),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (showRing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.full.copyWith(
|
||||
side: BorderSide(
|
||||
color: foregroundColor.withValues(
|
||||
alpha: widget.isSelected ? 0.9 : 0.5,
|
||||
),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${widget.tone}',
|
||||
style: AppTextStyle.caption2
|
||||
.of(context, emphasized: widget.isSelected)
|
||||
.copyWith(
|
||||
color: foregroundColor.withValues(
|
||||
alpha: widget.isSelected ? 1 : 0.72,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSchemePreview extends StatelessWidget {
|
||||
const _ColorSchemePreview();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final roles = [
|
||||
(
|
||||
colorScheme.primary,
|
||||
colorScheme.onPrimary,
|
||||
colorScheme.primaryContainer,
|
||||
'Primary',
|
||||
),
|
||||
(
|
||||
colorScheme.secondary,
|
||||
colorScheme.onSecondary,
|
||||
colorScheme.secondaryContainer,
|
||||
'Secondary',
|
||||
),
|
||||
(
|
||||
colorScheme.tertiary,
|
||||
colorScheme.onTertiary,
|
||||
colorScheme.tertiaryContainer,
|
||||
'Tertiary',
|
||||
),
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_previewInset),
|
||||
decoration: ShapeDecoration(
|
||||
color: context.raisedSurfaceLevel.colorOf(colorScheme),
|
||||
shape: AppShape.lg,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) => Row(
|
||||
spacing: min(_previewInset, constraints.maxWidth / 8),
|
||||
children: [
|
||||
for (final (color, onColor, container, label) in roles)
|
||||
Expanded(
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(AppCorner.lg - _previewInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
height: 44,
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTextStyle.caption2
|
||||
.of(context)
|
||||
.copyWith(color: onColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(height: 24, color: container),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return FilledInputTheme(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
style: const TextStyle(fontFeatures: [FontFeature.tabularFigures()]),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.appLocalizations.hex,
|
||||
prefixText: '#',
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp('[0-9a-fA-F]')),
|
||||
LengthLimitingTextInputFormatter(_length),
|
||||
TextInputFormatter.withFunction(
|
||||
(_, value) => value.copyWith(text: value.text.toUpperCase()),
|
||||
),
|
||||
],
|
||||
onChanged: (text) {
|
||||
final color = _parse(text);
|
||||
if (color != null) {
|
||||
widget.onChanged(color);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export 'active_polling.dart';
|
||||
export 'chip_title.dart';
|
||||
export 'color_scheme_box.dart';
|
||||
export 'config_item.dart';
|
||||
export 'icon.dart';
|
||||
export 'input.dart';
|
||||
|
||||
+122
-11
@@ -12,6 +12,21 @@ Hct _hct(Color color) => Hct.fromInt(color.toARGB32());
|
||||
Matcher _grayish(Color material, double limit) =>
|
||||
lessThanOrEqualTo(max(limit, _hct(material).chroma + 0.01));
|
||||
|
||||
const _accentStyles = {ColorStyle.vivid, ColorStyle.tinted};
|
||||
|
||||
const _materialVariants = {
|
||||
ColorStyle.vivid: DynamicSchemeVariant.content,
|
||||
ColorStyle.tinted: DynamicSchemeVariant.content,
|
||||
ColorStyle.soft: DynamicSchemeVariant.tonalSpot,
|
||||
ColorStyle.monochrome: DynamicSchemeVariant.monochrome,
|
||||
};
|
||||
|
||||
double _contrast(Color a, Color b) {
|
||||
final lighter = max(a.computeLuminance(), b.computeLuminance());
|
||||
final darker = min(a.computeLuminance(), b.computeLuminance());
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('opacity extensions use their named alpha values', () {
|
||||
const color = Colors.blue;
|
||||
@@ -31,20 +46,21 @@ void main() {
|
||||
Color(0xFF03A9F4),
|
||||
Color(0xFFE91E63),
|
||||
]) {
|
||||
for (final variant in DynamicSchemeVariant.values) {
|
||||
for (final style in ColorStyle.values) {
|
||||
for (final brightness in Brightness.values) {
|
||||
final dark = brightness == Brightness.dark;
|
||||
final scheme = seededColorScheme(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
variant: variant,
|
||||
style: style,
|
||||
);
|
||||
final material = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
dynamicSchemeVariant: variant,
|
||||
dynamicSchemeVariant: _materialVariants[style]!,
|
||||
);
|
||||
final reason = '$seed $variant $brightness';
|
||||
final reason = '$seed $style $brightness';
|
||||
final gray = style != ColorStyle.tinted;
|
||||
final surfaces = {
|
||||
scheme.surfaceDim: material.surfaceDim,
|
||||
scheme.surfaceContainerLowest: material.surfaceContainerLowest,
|
||||
@@ -54,7 +70,9 @@ void main() {
|
||||
scheme.surfaceContainerHighest: material.surfaceContainerHighest,
|
||||
};
|
||||
for (final MapEntry(key: ours, value: theirs) in surfaces.entries) {
|
||||
expect(_hct(ours).chroma, _grayish(theirs, 3.1), reason: reason);
|
||||
if (gray) {
|
||||
expect(_hct(ours).chroma, _grayish(theirs, 3.1), reason: reason);
|
||||
}
|
||||
expect(
|
||||
_hct(ours).tone,
|
||||
closeTo(_hct(theirs).tone, 0.6),
|
||||
@@ -66,11 +84,13 @@ void main() {
|
||||
scheme.surfaceBright: material.surfaceBright,
|
||||
};
|
||||
for (final MapEntry(key: ours, value: theirs) in papers.entries) {
|
||||
expect(
|
||||
_hct(ours).chroma,
|
||||
_grayish(theirs, dark ? 3.1 : 1.6),
|
||||
reason: reason,
|
||||
);
|
||||
if (gray) {
|
||||
expect(
|
||||
_hct(ours).chroma,
|
||||
_grayish(theirs, dark ? 3.1 : 1.6),
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
_hct(scheme.primaryContainer).tone,
|
||||
@@ -87,13 +107,104 @@ void main() {
|
||||
closeTo(dark ? 90 : 30, 1),
|
||||
reason: reason,
|
||||
);
|
||||
expect(scheme.primary, material.primary, reason: reason);
|
||||
if (!_accentStyles.contains(style)) {
|
||||
expect(scheme.primary, material.primary, reason: reason);
|
||||
}
|
||||
expect(scheme.onSurface, material.onSurface, reason: reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('vivid and tinted keep the seed as primary, lifted in dark', () {
|
||||
const blue = Color(0xFF007AFF);
|
||||
for (final style in _accentStyles) {
|
||||
final light = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.light,
|
||||
style: style,
|
||||
);
|
||||
final dark = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.dark,
|
||||
style: style,
|
||||
);
|
||||
|
||||
expect(light.primary, blue, reason: '$style');
|
||||
expect(light.onPrimary, Colors.white, reason: '$style');
|
||||
expect(_hct(dark.primary).hue, closeTo(_hct(blue).hue, 1));
|
||||
expect(_hct(dark.primary).tone, closeTo(_hct(blue).tone + 3, 0.5));
|
||||
expect(dark.onPrimary, Colors.white, reason: '$style');
|
||||
}
|
||||
|
||||
final soft = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.light,
|
||||
style: ColorStyle.soft,
|
||||
);
|
||||
expect(_hct(soft.primary).tone, closeTo(40, 1));
|
||||
expect(_hct(soft.primary).chroma, lessThan(_hct(blue).chroma * 0.6));
|
||||
});
|
||||
|
||||
test('tinted washes the surfaces with the seed and keeps their tones', () {
|
||||
const blue = Color(0xFF007AFF);
|
||||
for (final brightness in Brightness.values) {
|
||||
final vivid = seededColorScheme(seedColor: blue, brightness: brightness);
|
||||
final tinted = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: brightness,
|
||||
style: ColorStyle.tinted,
|
||||
);
|
||||
final reason = '$brightness';
|
||||
|
||||
expect(tinted.primary, vivid.primary, reason: reason);
|
||||
for (final (ours, plain) in [
|
||||
(tinted.surface, vivid.surface),
|
||||
(tinted.surfaceContainer, vivid.surfaceContainer),
|
||||
(tinted.surfaceContainerHighest, vivid.surfaceContainerHighest),
|
||||
]) {
|
||||
expect(_hct(ours).chroma, greaterThan(5), reason: reason);
|
||||
expect(_hct(ours).hue, closeTo(_hct(blue).hue, 15), reason: reason);
|
||||
expect(_hct(ours).tone, closeTo(_hct(plain).tone, 0.6), reason: reason);
|
||||
}
|
||||
}
|
||||
|
||||
final graphite = seededColorScheme(
|
||||
seedColor: const Color(0xFF8E8E93),
|
||||
brightness: Brightness.light,
|
||||
style: ColorStyle.tinted,
|
||||
);
|
||||
expect(_hct(graphite.surfaceContainer).chroma, lessThan(3.1));
|
||||
});
|
||||
|
||||
test('a primary kept from the seed stays readable under its text', () {
|
||||
for (var hue = 0.0; hue < 360; hue += 30) {
|
||||
for (final tone in [5.0, 35.0, 55.0, 70.0, 90.0, 98.0]) {
|
||||
final seed = Color(Hct.from(hue, 60, tone).toInt());
|
||||
for (final brightness in Brightness.values) {
|
||||
final scheme = seededColorScheme(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
final reason = '$seed $brightness';
|
||||
|
||||
expect(
|
||||
_contrast(scheme.primary, scheme.onPrimary),
|
||||
greaterThanOrEqualTo(3),
|
||||
reason: reason,
|
||||
);
|
||||
if (brightness == Brightness.dark) {
|
||||
expect(
|
||||
_contrast(scheme.primary, scheme.surface),
|
||||
greaterThanOrEqualTo(3),
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('status colors keep their hue whatever the seed and follow the '
|
||||
'brightness', () {
|
||||
ColorScheme of(Color seed, Brightness brightness) =>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/migration.dart';
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:material_ui/material_ui.dart' show ThemeMode;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -183,6 +187,109 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
group('upgrade', () {
|
||||
Map<String, Object?> customized() =>
|
||||
_createConfigMap()
|
||||
..['themeProps'] = {
|
||||
'primaryColor': 0xFF123456,
|
||||
'primaryColors': [0xFF795548, 0xFF123456],
|
||||
'schemeVariant': 'tonalSpot',
|
||||
'themeMode': 'dark',
|
||||
'pureBlack': true,
|
||||
'liquidGlass': true,
|
||||
'systemFontFamily': 'Beta Serif',
|
||||
};
|
||||
|
||||
test('starts theme colors over once and keeps the rest', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: customized(),
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: 0,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
final theme = config.themeProps;
|
||||
expect(theme.primaryColor, defaultPrimaryColor);
|
||||
expect(theme.primaryColors, defaultPrimaryColors);
|
||||
expect(theme.colorStyle, ColorStyle.vivid);
|
||||
expect(theme.themeMode, ThemeMode.dark);
|
||||
expect(theme.pureBlack, isTrue);
|
||||
expect(theme.liquidGlass, LiquidGlassMode.ultra);
|
||||
expect(theme.fontFamily, 'Beta Serif');
|
||||
expect(store.savedConfig, config);
|
||||
expect(store.version, Migration.currentVersion);
|
||||
expect(store.upgradeVersion, Migration.upgradeVersion);
|
||||
expect(store.events, [
|
||||
'getConfigMap',
|
||||
'getVersion',
|
||||
'saveConfig',
|
||||
'setUpgradeVersion',
|
||||
]);
|
||||
|
||||
final later = _FakeMigrationStore(
|
||||
configMap: jsonDecode(
|
||||
jsonEncode(config.copyWith.themeProps(primaryColor: 0xFF123456)),
|
||||
),
|
||||
version: store.version,
|
||||
upgradeVersion: store.upgradeVersion,
|
||||
);
|
||||
final kept = await Migration(store: later).run();
|
||||
|
||||
expect(kept.themeProps.primaryColor, 0xFF123456);
|
||||
expect(later.events, ['getConfigMap', 'getVersion']);
|
||||
});
|
||||
|
||||
test('keeps standard glass on the ultra tier it became, once', () async {
|
||||
Future<LiquidGlassMode> upgrade(String mode, int from) async {
|
||||
final configMap = _createConfigMap();
|
||||
(configMap['themeProps']! as Map<String, Object?>)['liquidGlass'] =
|
||||
mode;
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: configMap,
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: from,
|
||||
);
|
||||
return (await Migration(store: store).run()).themeProps.liquidGlass;
|
||||
}
|
||||
|
||||
expect(await upgrade('standard', 1), LiquidGlassMode.ultra);
|
||||
expect(await upgrade('none', 1), LiquidGlassMode.none);
|
||||
expect(await upgrade('standard', 2), LiquidGlassMode.standard);
|
||||
});
|
||||
|
||||
test('a new install only records it', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: null,
|
||||
version: 0,
|
||||
upgradeVersion: 0,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
expect(config.themeProps, defaultThemeProps);
|
||||
expect(store.upgradeVersion, Migration.upgradeVersion);
|
||||
expect(
|
||||
store.events.where((event) => event == 'saveConfig'),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('waits for the next launch when saving fails', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: customized(),
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: 0,
|
||||
configSaveResult: false,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
expect(config.themeProps.primaryColor, 0xFF123456);
|
||||
expect(store.upgradeVersion, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps what it can read of a damaged current config', () async {
|
||||
final configMap = _createConfigMap()
|
||||
..['currentProfileId'] = 7
|
||||
@@ -242,6 +349,7 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
final List<String> events = [];
|
||||
|
||||
int version;
|
||||
int upgradeVersion;
|
||||
Config? savedConfig;
|
||||
MigrationData? restoredData;
|
||||
bool didClearClashConfig = false;
|
||||
@@ -249,6 +357,7 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
_FakeMigrationStore({
|
||||
required this.configMap,
|
||||
required this.version,
|
||||
this.upgradeVersion = Migration.upgradeVersion,
|
||||
this.clashConfigMap,
|
||||
this.configSaveResult = true,
|
||||
this.available = true,
|
||||
@@ -302,4 +411,13 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
events.add('setVersion');
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getUpgradeVersion() async => upgradeVersion;
|
||||
|
||||
@override
|
||||
Future<void> setUpgradeVersion(int version) async {
|
||||
events.add('setUpgradeVersion');
|
||||
upgradeVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ class TestApp extends StatelessWidget {
|
||||
final List<Override> overrides;
|
||||
final Widget Function(Widget child) homeBuilder;
|
||||
final Locale? locale;
|
||||
final ThemeData? theme;
|
||||
final ThemeData? darkTheme;
|
||||
final ThemeMode? themeMode;
|
||||
|
||||
const TestApp({
|
||||
super.key,
|
||||
@@ -26,6 +29,9 @@ class TestApp extends StatelessWidget {
|
||||
this.overrides = const [],
|
||||
this.homeBuilder = _identity,
|
||||
this.locale,
|
||||
this.theme,
|
||||
this.darkTheme,
|
||||
this.themeMode,
|
||||
});
|
||||
|
||||
static Widget _identity(Widget child) => child;
|
||||
@@ -35,6 +41,9 @@ class TestApp extends StatelessWidget {
|
||||
final app = MaterialApp(
|
||||
navigatorKey: includeNavigatorKey ? globalState.navigatorKey : null,
|
||||
locale: locale,
|
||||
theme: theme,
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
AppUiLocalizations.delegate,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:fl_clash/manager/theme_manager.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -30,4 +32,37 @@ void main() {
|
||||
|
||||
expect(scaled, closeTo(13, 1e-9));
|
||||
});
|
||||
|
||||
testWidgets('a theme setting the theme does not read keeps the same theme', (
|
||||
tester,
|
||||
) async {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final themes = <ThemeData>[];
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
ref.watch(themeSettingProvider);
|
||||
themes.add(watchAppTheme(ref, Brightness.dark));
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
void update(ThemeProps Function(ThemeProps props) change) {
|
||||
final notifier = container.read(themeSettingProvider.notifier);
|
||||
notifier.value = change(notifier.value);
|
||||
}
|
||||
|
||||
update((props) => props.copyWith(sidebarBlur: !props.sidebarBlur));
|
||||
await tester.pump();
|
||||
update((props) => props.copyWith(pureBlack: !props.pureBlack));
|
||||
await tester.pump();
|
||||
|
||||
expect(themes, hasLength(3));
|
||||
expect(themes[1], same(themes[0]));
|
||||
expect(themes[2], isNot(same(themes[1])));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@@ -716,6 +717,42 @@ void main() {
|
||||
expect(restored.pureBlack, true);
|
||||
expect(restored.textScale.scale, 1.5);
|
||||
});
|
||||
|
||||
test('reads an old scheme variant as the nearest color style', () {
|
||||
ColorStyle styleOf(String variant) =>
|
||||
ThemeProps.fromJson({'schemeVariant': variant}).colorStyle;
|
||||
|
||||
expect(styleOf('content'), ColorStyle.vivid);
|
||||
expect(styleOf('fidelity'), ColorStyle.vivid);
|
||||
expect(styleOf('vibrant'), ColorStyle.vivid);
|
||||
expect(styleOf('tonalSpot'), ColorStyle.soft);
|
||||
expect(styleOf('neutral'), ColorStyle.soft);
|
||||
expect(styleOf('monochrome'), ColorStyle.monochrome);
|
||||
expect(
|
||||
ThemeProps.fromJson({
|
||||
'schemeVariant': 'monochrome',
|
||||
'colorStyle': 'tinted',
|
||||
}).colorStyle,
|
||||
ColorStyle.tinted,
|
||||
);
|
||||
expect(const ThemeProps().toJson(), isNot(contains('schemeVariant')));
|
||||
});
|
||||
|
||||
test('reads the old liquid glass switch as the standard tier', () {
|
||||
LiquidGlassMode modeOf(Object? value) =>
|
||||
ThemeProps.fromJson({'liquidGlass': value}).liquidGlass;
|
||||
|
||||
expect(modeOf(null), LiquidGlassMode.none);
|
||||
expect(modeOf(true), LiquidGlassMode.standard);
|
||||
expect(modeOf(false), LiquidGlassMode.none);
|
||||
expect(
|
||||
roundTrip(
|
||||
const ThemeProps(liquidGlass: LiquidGlassMode.standard).toJson,
|
||||
ThemeProps.fromJson,
|
||||
).liquidGlass,
|
||||
LiquidGlassMode.standard,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AccessControlProps', () {
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -760,10 +761,7 @@ void main() {
|
||||
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update(
|
||||
(state) =>
|
||||
state.copyWith(schemeVariant: DynamicSchemeVariant.tonalSpot),
|
||||
);
|
||||
.update((state) => state.copyWith(colorStyle: ColorStyle.soft));
|
||||
expect(container.read(provider), isNot(same(first)));
|
||||
});
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ void main() {
|
||||
accentColor: const Color(defaultPrimaryColor),
|
||||
);
|
||||
|
||||
final variant = container.read(themeSettingProvider).schemeVariant;
|
||||
final style = container.read(themeSettingProvider).colorStyle;
|
||||
|
||||
expect(
|
||||
container.read(genColorSchemeProvider(Brightness.light)),
|
||||
seededColorScheme(
|
||||
seedColor: lightSeed,
|
||||
brightness: Brightness.light,
|
||||
variant: variant,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
@@ -47,7 +47,7 @@ void main() {
|
||||
seededColorScheme(
|
||||
seedColor: darkSeed,
|
||||
brightness: Brightness.dark,
|
||||
variant: variant,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -91,7 +91,7 @@ void main() {
|
||||
seededColorScheme(
|
||||
seedColor: accent,
|
||||
brightness: Brightness.light,
|
||||
variant: container.read(themeSettingProvider).schemeVariant,
|
||||
style: container.read(themeSettingProvider).colorStyle,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
+202
-28
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/feature.dart';
|
||||
import 'package:fl_clash/common/system_fonts.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
@@ -76,25 +78,171 @@ void main() {
|
||||
});
|
||||
|
||||
group('primary color', () {
|
||||
testWidgets('blocks the back gesture only while a color is removable', (
|
||||
Finder swatch(int color) => find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == Color(color).hex,
|
||||
);
|
||||
|
||||
testWidgets('starts on blue among the system colors', (tester) async {
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((_) => defaultThemeProps);
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readTheme().primaryColor, 0xFF007AFF);
|
||||
for (final color in defaultPrimaryColors) {
|
||||
expect(swatch(color), findsOneWidget);
|
||||
}
|
||||
expect(find.byTooltip('Reset'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('picks a color and the style of its scheme', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(swatch(0xFFAF52DE));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().primaryColor, 0xFFAF52DE);
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == 'Follow system',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().primaryColor, isNull);
|
||||
|
||||
await tester.tap(find.text('Color style'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Soft').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().colorStyle, ColorStyle.soft);
|
||||
});
|
||||
|
||||
testWidgets('deletes a color from its context menu', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
await tester.tap(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFFF9500)));
|
||||
expect(readTheme().primaryColor, defaultPrimaryColor);
|
||||
expect(swatch(0xFFFF9500), findsNothing);
|
||||
});
|
||||
|
||||
Future<void> editColor(WidgetTester tester, int color, String hex) async {
|
||||
await tester.longPress(swatch(color));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Edit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(HexColorField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
hex,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('edits a color in place from its context menu', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
await tester.tap(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
final index = readTheme().primaryColors.indexOf(0xFFFF9500);
|
||||
|
||||
await editColor(tester, 0xFFFF9500, '123456');
|
||||
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFFF9500)));
|
||||
expect(readTheme().primaryColors[index], 0xFF123456);
|
||||
expect(readTheme().primaryColor, 0xFF123456);
|
||||
expect(swatch(0xFF123456), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('merges a color edited into one already listed', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpThemeView(tester);
|
||||
RoutePopDisposition disposition() {
|
||||
return ModalRoute.of(
|
||||
tester.element(find.byType(ThemeView)),
|
||||
)!.popDisposition;
|
||||
await tester.tap(swatch(0xFF007AFF));
|
||||
await tester.pumpAndSettle();
|
||||
final count = readTheme().primaryColors.length;
|
||||
|
||||
await editColor(tester, 0xFFAF52DE, '007AFF');
|
||||
|
||||
expect(readTheme().primaryColors, hasLength(count - 1));
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFAF52DE)));
|
||||
expect(readTheme().primaryColor, 0xFF007AFF);
|
||||
expect(swatch(0xFF007AFF), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('adds a custom color once and selects it', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
final count = readTheme().primaryColors.length;
|
||||
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await tester.tap(find.byTooltip('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(HexColorField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'123456',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
expect(readTheme().primaryColors, hasLength(count + 1));
|
||||
expect(readTheme().primaryColors.last, 0xFF123456);
|
||||
expect(readTheme().primaryColor, 0xFF123456);
|
||||
expect(swatch(0xFF123456), findsOneWidget);
|
||||
});
|
||||
|
||||
await tester.longPress(find.byType(ColorSchemeBox).at(1));
|
||||
testWidgets('keeps the custom color palette clear of the edge back '
|
||||
'swipe', (tester) async {
|
||||
const size = Size(400, 860);
|
||||
container.read(viewSizeProvider.notifier).value = size;
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.systemGestureInsets = const FakeViewPadding(
|
||||
left: 30,
|
||||
right: 30,
|
||||
);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: ThemeView()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), RoutePopDisposition.doNotPop);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.scrollUntilVisible(
|
||||
find.byTooltip('Add'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
|
||||
final pad = tester.getRect(
|
||||
find.descendant(
|
||||
of: find.byType(Palette),
|
||||
matching: find.byType(AspectRatio),
|
||||
),
|
||||
);
|
||||
expect(pad.left, 30);
|
||||
expect(pad.right, size.width - 30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,24 +275,26 @@ void main() {
|
||||
});
|
||||
|
||||
group('liquid glass', () {
|
||||
testWidgets('is off until chosen, and switches back', (tester) async {
|
||||
testWidgets('is off until a tier is chosen, and switches back', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpThemeView(tester);
|
||||
final group = find.ancestor(
|
||||
of: find.text('Liquid Glass').first,
|
||||
matching: find.byType(PreviewChoiceGroup<bool>),
|
||||
matching: find.byType(PreviewChoiceGroup<LiquidGlassMode>),
|
||||
);
|
||||
Finder choice(String label) =>
|
||||
find.descendant(of: group, matching: find.text(label)).last;
|
||||
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
|
||||
await tester.tap(choice('Liquid Glass'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isTrue);
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.none);
|
||||
|
||||
await tester.tap(choice('Standard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.standard);
|
||||
|
||||
await tester.tap(choice('None'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.none);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +384,7 @@ void main() {
|
||||
String? previewFamilyOf(WidgetTester tester, String text) =>
|
||||
DefaultTextStyle.of(tester.element(inPage(text))).style.fontFamily;
|
||||
|
||||
Future<void> pumpPastArrivalHold(WidgetTester tester) async {
|
||||
Future<void> pumpSettled(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -242,7 +392,7 @@ void main() {
|
||||
Future<void> openFonts(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pumpAndSettle();
|
||||
await pumpPastArrivalHold(tester);
|
||||
await pumpSettled(tester);
|
||||
}
|
||||
|
||||
testWidgets('picks an installed font on its page and back to default', (
|
||||
@@ -271,26 +421,50 @@ void main() {
|
||||
expect(readTheme().fontFamily, isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows the loading illustration until the sheet has opened', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('lists the fonts as the sheet opens and gives the names '
|
||||
'their fonts once it has stopped', (tester) async {
|
||||
var reads = 0;
|
||||
systemFontFamiliesReader = () async {
|
||||
reads++;
|
||||
return ['Alpha Sans'];
|
||||
return ['Arrival Sans'];
|
||||
};
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.byType(FontFamilyView), findsOneWidget);
|
||||
expect(reads, 0);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(reads, 1);
|
||||
expect(fontsIllustration, findsNothing);
|
||||
expect(inPage('Arrival Sans'), findsOneWidget);
|
||||
expect(
|
||||
isRouteMoving(tester.element(find.byType(FontFamilyView))),
|
||||
isTrue,
|
||||
);
|
||||
expect(previewFamilyOf(tester, 'Arrival Sans'), isNot('Arrival Sans'));
|
||||
|
||||
await pumpSettled(tester);
|
||||
expect(previewFamilyOf(tester, 'Arrival Sans'), 'Arrival Sans');
|
||||
});
|
||||
|
||||
testWidgets('shows the loading illustration while the fonts take long', (
|
||||
tester,
|
||||
) async {
|
||||
final families = Completer<List<String>>();
|
||||
systemFontFamiliesReader = () => families.future;
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(fontsIllustration, findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(fontsIllustration, findsOneWidget);
|
||||
expect(inPage('Default'), findsNothing);
|
||||
|
||||
await pumpPastArrivalHold(tester);
|
||||
expect(reads, 1);
|
||||
families.complete(['Alpha Sans']);
|
||||
await pumpSettled(tester);
|
||||
expect(inPage('Alpha Sans'), findsOneWidget);
|
||||
expect(fontsIllustration, findsNothing);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:fl_clash/ui/sheet.dart';
|
||||
import 'package:fl_clash/ui/sheet_header.dart';
|
||||
import 'package:fl_clash/ui/snap_sheet.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -154,6 +155,75 @@ void main() {
|
||||
expect(sheetTop(tester), closeTo(open, 1));
|
||||
});
|
||||
|
||||
testWidgets('a flick up from the short detent rises to the height of '
|
||||
'content a little taller than it without dropping on the way', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 3000);
|
||||
var top = sheetTop(tester);
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
|
||||
top = sheetTop(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), greaterThan(_tallTop + 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a drag let go nearer the height of content a little taller '
|
||||
'than the short detent than the detent opens the sheet to it', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
final short = sheetTop(tester);
|
||||
|
||||
final gesture = await tester.startGesture(tester.getCenter(content()));
|
||||
for (var move = 0; move < 6; move++) {
|
||||
await gesture.moveBy(const Offset(0, -20));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), lessThan(short - 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a wheel tick opens a sheet a little taller than the short '
|
||||
'detent to its content height without dropping on the way', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
var top = sheetTop(tester);
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
|
||||
top = sheetTop(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), greaterThan(_tallTop + 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
double rowBottom(WidgetTester tester, int row) =>
|
||||
tester.getBottomLeft(find.widgetWithText(ListTile, 'row $row')).dy;
|
||||
|
||||
@@ -211,21 +281,128 @@ void main() {
|
||||
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
|
||||
});
|
||||
|
||||
testWidgets('a long sheet at the tall detent rises with the keyboard and '
|
||||
'comes back down with it on every frame', (tester) async {
|
||||
testWidgets('a long sheet at the tall detent rises with the keyboard into '
|
||||
'the peek and comes back down with it on every frame', (tester) async {
|
||||
await openSheet(tester, (_) => _list(60));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.fling(content(), const Offset(0, -200), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheetTop(tester), closeTo(_tallTop, 1));
|
||||
|
||||
for (final inset in [40.0, 120.0, 300.0, 200.0, 60.0, 20.0, 0.0]) {
|
||||
var top = _tallTop;
|
||||
var last = 0.0;
|
||||
for (final inset in [40.0, 120.0, 300.0, 120.0, 40.0]) {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: inset);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final moved = sheetTop(tester);
|
||||
expect(
|
||||
sheetTop(tester),
|
||||
closeTo(math.max(stackedSheetPeek, _tallTop - inset), 1),
|
||||
moved,
|
||||
inInclusiveRange(
|
||||
math.max(stackedSheetPeek, _tallTop - inset) - 1,
|
||||
_tallTop - 1,
|
||||
),
|
||||
);
|
||||
expect(inset > last ? top - moved : moved - top, greaterThan(1));
|
||||
top = moved;
|
||||
last = inset;
|
||||
}
|
||||
expect(top, greaterThan(stackedSheetPeek + 1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
|
||||
tester.view.resetViewInsets();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), closeTo(_tallTop, 1));
|
||||
});
|
||||
|
||||
Future<List<double>> trackKeyboard(
|
||||
WidgetTester tester,
|
||||
Iterable<double> insets,
|
||||
) async {
|
||||
final tops = <double>[];
|
||||
for (final inset in insets) {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: inset);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
tops.add(sheetTop(tester));
|
||||
}
|
||||
return tops;
|
||||
}
|
||||
|
||||
/// A keyboard moving at a steady speed never jolts the sheet: its speed
|
||||
/// changes by a small part of the keyboard's from one frame to the next.
|
||||
void expectEased(List<double> tops, double step) {
|
||||
for (var index = 2; index < tops.length; index++) {
|
||||
final speed = tops[index] - tops[index - 1];
|
||||
final before = tops[index - 1] - tops[index - 2];
|
||||
expect((speed - before).abs(), lessThan(step / 4));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('a short sheet starts and stops rising with the keyboard and '
|
||||
'keeps its content above it', (tester) async {
|
||||
await openSheet(tester, (_) => _list(3));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final rest = sheetTop(tester);
|
||||
const step = 10.0;
|
||||
final insets = [for (var inset = 0.0; inset <= 400; inset += step) inset];
|
||||
|
||||
final rising = await trackKeyboard(tester, insets);
|
||||
expect(rising.last, lessThan(rest - 1));
|
||||
expect(rowBottom(tester, 2), lessThanOrEqualTo(_view.height - 400));
|
||||
expectEased(rising, step);
|
||||
|
||||
final falling = await trackKeyboard(tester, insets.reversed);
|
||||
expectEased(falling, step);
|
||||
expect(falling.last, closeTo(rest, 0.01));
|
||||
});
|
||||
|
||||
testWidgets('a long sheet at the tall detent slows into the peek with the '
|
||||
'keyboard', (tester) async {
|
||||
await openSheet(tester, (_) => _list(60));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.fling(content(), const Offset(0, -200), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
const step = 8.0;
|
||||
final insets = [for (var inset = 0.0; inset <= 240; inset += step) inset];
|
||||
|
||||
final tops = await trackKeyboard(tester, insets);
|
||||
expectEased(tops, step);
|
||||
expect(tops.last, closeTo(stackedSheetPeek, 1));
|
||||
});
|
||||
|
||||
testWidgets('the keyboard moves a sheet without laying out or painting its '
|
||||
'content again', (tester) async {
|
||||
for (final rows in [3, 60]) {
|
||||
final counts = _Counts();
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) => CommonScaffold(
|
||||
title: 'title',
|
||||
body: _Counted(
|
||||
counts: counts,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (var index = 0; index < rows; index++)
|
||||
SizedBox(height: _rowHeight, child: Text('row $index')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
counts
|
||||
..layouts = 0
|
||||
..paints = 0;
|
||||
|
||||
final tops = await trackKeyboard(tester, [100, 200, 300, 200, 100, 0]);
|
||||
expect(tops.toSet(), hasLength(greaterThan(2)), reason: '$rows rows');
|
||||
expect(counts.layouts, 0, reason: '$rows rows');
|
||||
expect(counts.paints, 0, reason: '$rows rows');
|
||||
|
||||
Navigator.of(tester.element(find.text('row 0'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -530,3 +707,36 @@ void main() {
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||
);
|
||||
}
|
||||
|
||||
class _Counts {
|
||||
var layouts = 0;
|
||||
var paints = 0;
|
||||
}
|
||||
|
||||
class _Counted extends SingleChildRenderObjectWidget {
|
||||
const _Counted({required this.counts, required super.child});
|
||||
|
||||
final _Counts counts;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderCounted(counts);
|
||||
}
|
||||
|
||||
class _RenderCounted extends RenderProxyBox {
|
||||
_RenderCounted(this.counts);
|
||||
|
||||
final _Counts counts;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
counts.layouts++;
|
||||
super.performLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
counts.paints++;
|
||||
super.paint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/theme.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void _noop() {}
|
||||
@@ -15,16 +13,30 @@ const _width = 520.0;
|
||||
List<Widget> _actions() => const [
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Delete',
|
||||
onPressed: _noop,
|
||||
icon: GlyphIcon(AppGlyphs.delete, fill: 1),
|
||||
),
|
||||
),
|
||||
CommonMinFilledButtonTheme(
|
||||
child: FilledButton(onPressed: _noop, child: Text('Select all')),
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Select all',
|
||||
onPressed: _noop,
|
||||
icon: GlyphIcon(AppGlyphs.selectAll),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
const _data = [
|
||||
IconButtonData(glyph: AppGlyphs.delete, tooltip: 'Delete', onPressed: _noop),
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.selectAll,
|
||||
tooltip: 'Select all',
|
||||
onPressed: _noop,
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
tester.view.physicalSize = const Size(_width, 200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -40,49 +52,29 @@ Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getRect(find.byType(FilledButton));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('both headers end their actions at the same inset', (
|
||||
tester,
|
||||
) async {
|
||||
final list = await _pumpHeader(
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
ListHeader(title: 'Exclude SSIDs', actions: _actions()),
|
||||
const ListHeader(title: 'Exclude SSIDs', actions: _data),
|
||||
);
|
||||
final info = await _pumpHeader(
|
||||
final list = tester.getRect(find.byTooltip('Select all'));
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Added rules'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final info = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(_width - list.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a card header ends where a plain one does', (tester) async {
|
||||
final info = await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final card = await _pumpHeader(
|
||||
tester,
|
||||
ItemCard(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
space: 8,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_width - card.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a header spaces its actions like an app bar', (tester) async {
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
@@ -91,11 +83,29 @@ void main() {
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final icon = tester.getRect(
|
||||
find.widgetWithGlyph(IconButton, AppGlyphs.delete),
|
||||
);
|
||||
final pill = tester.getRect(find.byType(FilledButton));
|
||||
final first = tester.getRect(find.byTooltip('Delete'));
|
||||
final second = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(pill.left - icon.right, appBarActionSpace);
|
||||
expect(second.left - first.right, appBarActionSpace);
|
||||
});
|
||||
|
||||
testWidgets('a list header stands as tall with actions as without', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHeader(tester, const ListHeader(title: 'Rules'));
|
||||
final bare = tester.getRect(find.text('Rules'));
|
||||
final bareHeader = tester.getSize(find.byType(ListHeader));
|
||||
await _pumpHeader(tester, const ListHeader(title: 'Rules', actions: _data));
|
||||
final withActions = tester.getRect(find.text('Rules'));
|
||||
final button = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(withActions.topLeft, bare.topLeft);
|
||||
expect(tester.getSize(find.byType(ListHeader)).height, bareHeader.height);
|
||||
expect(button.height, TonalButtonSize.section.button);
|
||||
expect(button.center.dy, closeTo(withActions.center.dy, 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(ListHeader)).dy - button.bottom,
|
||||
greaterThanOrEqualTo(6),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+126
-42
@@ -5,64 +5,148 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Palette updates color from hue, chroma, and tone controls', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = ValueNotifier<Color>(Colors.blue);
|
||||
addTearDown(controller.dispose);
|
||||
late ValueNotifier<Color> controller;
|
||||
|
||||
await tester.pumpWidget(
|
||||
setUp(() => controller = ValueNotifier(const Color(0xFF007AFF)));
|
||||
tearDown(() => controller.dispose());
|
||||
|
||||
Future<void> pumpPalette(WidgetTester tester, {ScrollController? scroll}) {
|
||||
return tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SizedBox(width: 700, child: Palette(controller: controller)),
|
||||
body: ListView(
|
||||
controller: scroll,
|
||||
children: [
|
||||
SizedBox(width: 360, child: Palette(controller: controller)),
|
||||
const SizedBox(height: 1200),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.byType(Slider), findsNWidgets(2));
|
||||
expect(find.text('Preview'), findsOneWidget);
|
||||
expect(find.text('Primary'), findsOneWidget);
|
||||
expect(find.text('100'), findsOneWidget);
|
||||
final pad = find.descendant(
|
||||
of: find.byType(Palette),
|
||||
matching: find.byType(AspectRatio),
|
||||
);
|
||||
|
||||
final initial = controller.value;
|
||||
tester.widget<Slider>(find.byType(Slider).first).onChanged!(180);
|
||||
await tester.pump();
|
||||
expect(controller.value, isNot(initial));
|
||||
Offset at(WidgetTester tester, double x, double y) {
|
||||
final rect = tester.getRect(pad);
|
||||
return rect.topLeft + Offset(rect.width * x, rect.height * y);
|
||||
}
|
||||
|
||||
final hueColor = controller.value;
|
||||
tester.widget<Slider>(find.byType(Slider).last).onChanged!(8);
|
||||
await tester.pump();
|
||||
expect(controller.value, isNot(hueColor));
|
||||
testWidgets('the grid picks the cell under the finger', (tester) async {
|
||||
await pumpPalette(tester);
|
||||
|
||||
await tester.tap(find.text('0'));
|
||||
await tester.pump();
|
||||
expect(controller.value.computeLuminance(), lessThan(0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tapAt(at(tester, 0.02, 0.02));
|
||||
expect(controller.value, const Color(0xFFFFFFFF));
|
||||
|
||||
await tester.tapAt(at(tester, 0.98, 0.02));
|
||||
expect(controller.value, const Color(0xFF000000));
|
||||
|
||||
final hues = <double>{};
|
||||
final gesture = await tester.startGesture(at(tester, 0.04, 0.5));
|
||||
for (var x = 0.04; x < 1; x += 0.0833) {
|
||||
await gesture.moveTo(at(tester, x, 0.5));
|
||||
hues.add(HSVColor.fromColor(controller.value).hue.roundToDouble());
|
||||
}
|
||||
await gesture.up();
|
||||
expect(hues.length, 12);
|
||||
});
|
||||
|
||||
testWidgets('Palette lays out narrow tone and preview grids', (tester) async {
|
||||
final controller = ValueNotifier<Color>(Colors.white);
|
||||
addTearDown(controller.dispose);
|
||||
testWidgets('the spectrum runs hue across and lightness down, and keeps '
|
||||
'the drag from scrolling', (tester) async {
|
||||
final scroll = ScrollController();
|
||||
addTearDown(scroll.dispose);
|
||||
await pumpPalette(tester, scroll: scroll);
|
||||
await tester.tap(find.text('Spectrum'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SizedBox(width: 32, child: Palette(controller: controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tapAt(at(tester, 0.5, 0.5));
|
||||
final middle = HSLColor.fromColor(controller.value);
|
||||
expect(middle.hue, closeTo(180, 2));
|
||||
expect(middle.lightness, closeTo(0.5, 0.02));
|
||||
|
||||
final gesture = await tester.startGesture(at(tester, 0.5, 0.5));
|
||||
await gesture.moveBy(const Offset(0, -400));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
|
||||
expect(scroll.offset, 0);
|
||||
expect(controller.value, const Color(0xFFFFFFFF));
|
||||
});
|
||||
|
||||
testWidgets('the sliders keep the hue a gray drops', (tester) async {
|
||||
await pumpPalette(tester);
|
||||
await tester.tap(find.text('Sliders'));
|
||||
await tester.pumpAndSettle();
|
||||
Slider slider(int index) =>
|
||||
tester.widget<Slider>(find.byType(Slider).at(index));
|
||||
final hue = slider(0).value;
|
||||
|
||||
slider(1).onChanged!(0);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('100'), findsOneWidget);
|
||||
expect(find.text('Primary'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(HSVColor.fromColor(controller.value).saturation, 0);
|
||||
expect(slider(0).value, hue);
|
||||
|
||||
slider(1).onChanged!(1);
|
||||
await tester.pump();
|
||||
slider(2).onChanged!(0.5);
|
||||
await tester.pump();
|
||||
|
||||
final picked = HSVColor.fromColor(controller.value);
|
||||
expect(picked.hue, closeTo(hue, 1));
|
||||
expect(picked.value, closeTo(0.5, 0.01));
|
||||
expect(find.text('50%'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('hex field', () {
|
||||
Future<void> pumpField(WidgetTester tester) {
|
||||
return tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Scaffold(
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: controller,
|
||||
builder: (_, color, _) => HexColorField(
|
||||
color: color,
|
||||
onChanged: (color) => controller.value = color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String text(WidgetTester tester) =>
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text;
|
||||
|
||||
testWidgets('applies six digits and leaves a partial one alone', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpField(tester);
|
||||
expect(text(tester), '007AFF');
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ff3');
|
||||
await tester.pump();
|
||||
expect(controller.value, const Color(0xFF007AFF));
|
||||
expect(text(tester), 'FF3');
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ff3b30');
|
||||
await tester.pump();
|
||||
expect(controller.value, const Color(0xFFFF3B30));
|
||||
expect(text(tester), 'FF3B30');
|
||||
});
|
||||
|
||||
testWidgets('follows a color picked elsewhere', (tester) async {
|
||||
await pumpField(tester);
|
||||
|
||||
controller.value = const Color(0xFF34C759);
|
||||
await tester.pump();
|
||||
|
||||
expect(text(tester), '34C759');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
ThemeData _theme(Brightness brightness) => ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: brightness,
|
||||
),
|
||||
);
|
||||
|
||||
Future<ValueNotifier<ThemeMode>> _openFromPage(
|
||||
WidgetTester tester, {
|
||||
required Size size,
|
||||
required void Function(BuildContext context) open,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final mode = ValueNotifier(ThemeMode.light);
|
||||
addTearDown(mode.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: mode,
|
||||
builder: (_, themeMode, _) => TestApp(
|
||||
theme: _theme(Brightness.light),
|
||||
darkTheme: _theme(Brightness.dark),
|
||||
themeMode: themeMode,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => open(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
return mode;
|
||||
}
|
||||
|
||||
Color? _sheetColor(WidgetTester tester) => tester
|
||||
.widget<Material>(
|
||||
find
|
||||
.ancestor(of: find.text('sheet'), matching: find.byType(Material))
|
||||
.first,
|
||||
)
|
||||
.color;
|
||||
|
||||
void main() {
|
||||
final darkSheetColor = SurfaceLevel.page.colorOf(
|
||||
_theme(Brightness.dark).colorScheme,
|
||||
);
|
||||
|
||||
for (final (kind, size, type, show) in [
|
||||
('bottom sheet', const Size(400, 800), SheetType.bottomSheet, showSheet),
|
||||
('side sheet', const Size(1200, 800), SheetType.sideSheet, showSheet),
|
||||
('form sheet', const Size(1200, 800), SheetType.formSheet, showFormSheet),
|
||||
]) {
|
||||
testWidgets('an open $kind takes a new app theme', (tester) async {
|
||||
final mode = await _openFromPage(
|
||||
tester,
|
||||
size: size,
|
||||
open: (context) =>
|
||||
show<void>(context: context, builder: (_) => const Text('sheet')),
|
||||
);
|
||||
expect(SheetProvider.of(tester.element(find.text('sheet')))?.type, type);
|
||||
expect(_sheetColor(tester), isNot(darkSheetColor));
|
||||
|
||||
mode.value = ThemeMode.dark;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_sheetColor(tester), darkSheetColor);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -863,7 +863,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
homeBuilder: (child) => Theme(data: theme, child: child),
|
||||
theme: theme,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showSnapSheet<void>(context, builder: _sheet),
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:fl_clash/ui/theme_cross_fade.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _red = Color(0xFFFF0000);
|
||||
const _blue = Color(0xFF0000FF);
|
||||
const _green = Color(0xFF00FF00);
|
||||
|
||||
void main() {
|
||||
final shotKey = GlobalKey();
|
||||
late ValueNotifier<Color> background;
|
||||
|
||||
setUp(() => background = ValueNotifier(_red));
|
||||
tearDown(() => background.dispose());
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester, {double opacity = 1}) async {
|
||||
tester.view.physicalSize = const Size(40, 40);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: background,
|
||||
builder: (_, color, _) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(scaffoldBackgroundColor: color),
|
||||
themeAnimationStyle: AnimationStyle.noAnimation,
|
||||
builder: (_, child) => ThemeCrossFade(child: child!),
|
||||
home: Builder(
|
||||
builder: (context) => ColoredBox(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor.withValues(alpha: opacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Color> pixel(WidgetTester tester) async {
|
||||
final boundary =
|
||||
shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
late Color color;
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
final data = (await image.toByteData())!;
|
||||
image.dispose();
|
||||
color = Color.fromARGB(
|
||||
data.getUint8(3),
|
||||
data.getUint8(0),
|
||||
data.getUint8(1),
|
||||
data.getUint8(2),
|
||||
);
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
List<double> channels(Color color) => [color.a, color.r, color.g, color.b];
|
||||
|
||||
Matcher isColor(Color expected) => isA<Color>().having(
|
||||
channels,
|
||||
'channels',
|
||||
pairwiseCompare<double, double>(
|
||||
channels(expected),
|
||||
(expected, actual) => (expected - actual).abs() <= 2 / 255,
|
||||
'within 2/255 of',
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('shows the last frame over a new theme until the fade ends', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
expect(await pixel(tester), isColor(_red));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(await pixel(tester), isColor(Color.lerp(_red, _blue, 0.5)!));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(await pixel(tester), isColor(_blue));
|
||||
});
|
||||
|
||||
testWidgets('keeps a translucent pixel as opaque as it was', (tester) async {
|
||||
await pumpApp(tester, opacity: 0.5);
|
||||
final alpha = (await pixel(tester)).a;
|
||||
|
||||
background.value = _blue;
|
||||
for (var frame = 0; frame < 14; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(
|
||||
(await pixel(tester)).a,
|
||||
moreOrLessEquals(alpha, epsilon: 1 / 255),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a second change mid-fade starts from what is on screen', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final onScreen = await pixel(tester);
|
||||
|
||||
background.value = _green;
|
||||
await tester.pump();
|
||||
expect(await pixel(tester), isColor(onScreen));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(await pixel(tester), isColor(_green));
|
||||
});
|
||||
|
||||
testWidgets('switches at once when animations are disabled', (tester) async {
|
||||
tester.platformDispatcher.accessibilityFeaturesTestValue =
|
||||
const FakeAccessibilityFeatures(disableAnimations: true);
|
||||
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
|
||||
await pumpApp(tester);
|
||||
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
|
||||
expect(await pixel(tester), isColor(_blue));
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user