feat(theme): redesign theme colors with an iOS-style picker

Changelog: Redesigned theme colors and color styles
This commit is contained in:
chen08209
2026-10-16 22:42:17 +08:00
parent fea949adb1
commit 8a8627a853
41 changed files with 3603 additions and 1780 deletions
+11 -8
View File
@@ -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!),
);
},
+9 -7
View File
@@ -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 = '''
+64
View File
@@ -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;
+10
View File
@@ -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));
+28 -8
View File
@@ -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
View File
@@ -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.
+26 -26
View File
@@ -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?,
));
+22 -17
View File
@@ -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(
+23 -23
View File
@@ -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,
));
}
+2 -1
View File
@@ -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;
}
+17 -3
View File
@@ -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);
+16 -12
View File
@@ -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
View File
@@ -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));
-44
View File
@@ -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;
+5 -9
View File
@@ -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
View File
@@ -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,
),
),
);
}
-12
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+229
View File
@@ -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,
);
}
}
+1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+306 -197
View File
@@ -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;
}
}
}
-149
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) =>
+118
View File
@@ -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;
}
}
+9
View File
@@ -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,
+35
View File
@@ -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])));
});
}
+37
View File
@@ -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', () {
+2 -4
View File
@@ -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)));
});
+4 -4
View File
@@ -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
View File
@@ -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);
});
+215 -5
View File
@@ -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);
}
}
+45 -35
View File
@@ -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
View File
@@ -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');
});
});
}
+88
View File
@@ -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);
});
}
}
+1 -1
View File
@@ -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),
+134
View File
@@ -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);
});
}