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 9bd258f654
commit 5eafd7ad38
41 changed files with 3603 additions and 1780 deletions
+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);
});
}