feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
This commit is contained in:
+122
-11
@@ -12,6 +12,21 @@ Hct _hct(Color color) => Hct.fromInt(color.toARGB32());
|
||||
Matcher _grayish(Color material, double limit) =>
|
||||
lessThanOrEqualTo(max(limit, _hct(material).chroma + 0.01));
|
||||
|
||||
const _accentStyles = {ColorStyle.vivid, ColorStyle.tinted};
|
||||
|
||||
const _materialVariants = {
|
||||
ColorStyle.vivid: DynamicSchemeVariant.content,
|
||||
ColorStyle.tinted: DynamicSchemeVariant.content,
|
||||
ColorStyle.soft: DynamicSchemeVariant.tonalSpot,
|
||||
ColorStyle.monochrome: DynamicSchemeVariant.monochrome,
|
||||
};
|
||||
|
||||
double _contrast(Color a, Color b) {
|
||||
final lighter = max(a.computeLuminance(), b.computeLuminance());
|
||||
final darker = min(a.computeLuminance(), b.computeLuminance());
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('opacity extensions use their named alpha values', () {
|
||||
const color = Colors.blue;
|
||||
@@ -31,20 +46,21 @@ void main() {
|
||||
Color(0xFF03A9F4),
|
||||
Color(0xFFE91E63),
|
||||
]) {
|
||||
for (final variant in DynamicSchemeVariant.values) {
|
||||
for (final style in ColorStyle.values) {
|
||||
for (final brightness in Brightness.values) {
|
||||
final dark = brightness == Brightness.dark;
|
||||
final scheme = seededColorScheme(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
variant: variant,
|
||||
style: style,
|
||||
);
|
||||
final material = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
dynamicSchemeVariant: variant,
|
||||
dynamicSchemeVariant: _materialVariants[style]!,
|
||||
);
|
||||
final reason = '$seed $variant $brightness';
|
||||
final reason = '$seed $style $brightness';
|
||||
final gray = style != ColorStyle.tinted;
|
||||
final surfaces = {
|
||||
scheme.surfaceDim: material.surfaceDim,
|
||||
scheme.surfaceContainerLowest: material.surfaceContainerLowest,
|
||||
@@ -54,7 +70,9 @@ void main() {
|
||||
scheme.surfaceContainerHighest: material.surfaceContainerHighest,
|
||||
};
|
||||
for (final MapEntry(key: ours, value: theirs) in surfaces.entries) {
|
||||
expect(_hct(ours).chroma, _grayish(theirs, 3.1), reason: reason);
|
||||
if (gray) {
|
||||
expect(_hct(ours).chroma, _grayish(theirs, 3.1), reason: reason);
|
||||
}
|
||||
expect(
|
||||
_hct(ours).tone,
|
||||
closeTo(_hct(theirs).tone, 0.6),
|
||||
@@ -66,11 +84,13 @@ void main() {
|
||||
scheme.surfaceBright: material.surfaceBright,
|
||||
};
|
||||
for (final MapEntry(key: ours, value: theirs) in papers.entries) {
|
||||
expect(
|
||||
_hct(ours).chroma,
|
||||
_grayish(theirs, dark ? 3.1 : 1.6),
|
||||
reason: reason,
|
||||
);
|
||||
if (gray) {
|
||||
expect(
|
||||
_hct(ours).chroma,
|
||||
_grayish(theirs, dark ? 3.1 : 1.6),
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
_hct(scheme.primaryContainer).tone,
|
||||
@@ -87,13 +107,104 @@ void main() {
|
||||
closeTo(dark ? 90 : 30, 1),
|
||||
reason: reason,
|
||||
);
|
||||
expect(scheme.primary, material.primary, reason: reason);
|
||||
if (!_accentStyles.contains(style)) {
|
||||
expect(scheme.primary, material.primary, reason: reason);
|
||||
}
|
||||
expect(scheme.onSurface, material.onSurface, reason: reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('vivid and tinted keep the seed as primary, lifted in dark', () {
|
||||
const blue = Color(0xFF007AFF);
|
||||
for (final style in _accentStyles) {
|
||||
final light = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.light,
|
||||
style: style,
|
||||
);
|
||||
final dark = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.dark,
|
||||
style: style,
|
||||
);
|
||||
|
||||
expect(light.primary, blue, reason: '$style');
|
||||
expect(light.onPrimary, Colors.white, reason: '$style');
|
||||
expect(_hct(dark.primary).hue, closeTo(_hct(blue).hue, 1));
|
||||
expect(_hct(dark.primary).tone, closeTo(_hct(blue).tone + 3, 0.5));
|
||||
expect(dark.onPrimary, Colors.white, reason: '$style');
|
||||
}
|
||||
|
||||
final soft = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: Brightness.light,
|
||||
style: ColorStyle.soft,
|
||||
);
|
||||
expect(_hct(soft.primary).tone, closeTo(40, 1));
|
||||
expect(_hct(soft.primary).chroma, lessThan(_hct(blue).chroma * 0.6));
|
||||
});
|
||||
|
||||
test('tinted washes the surfaces with the seed and keeps their tones', () {
|
||||
const blue = Color(0xFF007AFF);
|
||||
for (final brightness in Brightness.values) {
|
||||
final vivid = seededColorScheme(seedColor: blue, brightness: brightness);
|
||||
final tinted = seededColorScheme(
|
||||
seedColor: blue,
|
||||
brightness: brightness,
|
||||
style: ColorStyle.tinted,
|
||||
);
|
||||
final reason = '$brightness';
|
||||
|
||||
expect(tinted.primary, vivid.primary, reason: reason);
|
||||
for (final (ours, plain) in [
|
||||
(tinted.surface, vivid.surface),
|
||||
(tinted.surfaceContainer, vivid.surfaceContainer),
|
||||
(tinted.surfaceContainerHighest, vivid.surfaceContainerHighest),
|
||||
]) {
|
||||
expect(_hct(ours).chroma, greaterThan(5), reason: reason);
|
||||
expect(_hct(ours).hue, closeTo(_hct(blue).hue, 15), reason: reason);
|
||||
expect(_hct(ours).tone, closeTo(_hct(plain).tone, 0.6), reason: reason);
|
||||
}
|
||||
}
|
||||
|
||||
final graphite = seededColorScheme(
|
||||
seedColor: const Color(0xFF8E8E93),
|
||||
brightness: Brightness.light,
|
||||
style: ColorStyle.tinted,
|
||||
);
|
||||
expect(_hct(graphite.surfaceContainer).chroma, lessThan(3.1));
|
||||
});
|
||||
|
||||
test('a primary kept from the seed stays readable under its text', () {
|
||||
for (var hue = 0.0; hue < 360; hue += 30) {
|
||||
for (final tone in [5.0, 35.0, 55.0, 70.0, 90.0, 98.0]) {
|
||||
final seed = Color(Hct.from(hue, 60, tone).toInt());
|
||||
for (final brightness in Brightness.values) {
|
||||
final scheme = seededColorScheme(
|
||||
seedColor: seed,
|
||||
brightness: brightness,
|
||||
);
|
||||
final reason = '$seed $brightness';
|
||||
|
||||
expect(
|
||||
_contrast(scheme.primary, scheme.onPrimary),
|
||||
greaterThanOrEqualTo(3),
|
||||
reason: reason,
|
||||
);
|
||||
if (brightness == Brightness.dark) {
|
||||
expect(
|
||||
_contrast(scheme.primary, scheme.surface),
|
||||
greaterThanOrEqualTo(3),
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('status colors keep their hue whatever the seed and follow the '
|
||||
'brightness', () {
|
||||
ColorScheme of(Color seed, Brightness brightness) =>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/migration.dart';
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:material_ui/material_ui.dart' show ThemeMode;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -183,6 +187,109 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
group('upgrade', () {
|
||||
Map<String, Object?> customized() =>
|
||||
_createConfigMap()
|
||||
..['themeProps'] = {
|
||||
'primaryColor': 0xFF123456,
|
||||
'primaryColors': [0xFF795548, 0xFF123456],
|
||||
'schemeVariant': 'tonalSpot',
|
||||
'themeMode': 'dark',
|
||||
'pureBlack': true,
|
||||
'liquidGlass': true,
|
||||
'systemFontFamily': 'Beta Serif',
|
||||
};
|
||||
|
||||
test('starts theme colors over once and keeps the rest', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: customized(),
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: 0,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
final theme = config.themeProps;
|
||||
expect(theme.primaryColor, defaultPrimaryColor);
|
||||
expect(theme.primaryColors, defaultPrimaryColors);
|
||||
expect(theme.colorStyle, ColorStyle.vivid);
|
||||
expect(theme.themeMode, ThemeMode.dark);
|
||||
expect(theme.pureBlack, isTrue);
|
||||
expect(theme.liquidGlass, LiquidGlassMode.ultra);
|
||||
expect(theme.fontFamily, 'Beta Serif');
|
||||
expect(store.savedConfig, config);
|
||||
expect(store.version, Migration.currentVersion);
|
||||
expect(store.upgradeVersion, Migration.upgradeVersion);
|
||||
expect(store.events, [
|
||||
'getConfigMap',
|
||||
'getVersion',
|
||||
'saveConfig',
|
||||
'setUpgradeVersion',
|
||||
]);
|
||||
|
||||
final later = _FakeMigrationStore(
|
||||
configMap: jsonDecode(
|
||||
jsonEncode(config.copyWith.themeProps(primaryColor: 0xFF123456)),
|
||||
),
|
||||
version: store.version,
|
||||
upgradeVersion: store.upgradeVersion,
|
||||
);
|
||||
final kept = await Migration(store: later).run();
|
||||
|
||||
expect(kept.themeProps.primaryColor, 0xFF123456);
|
||||
expect(later.events, ['getConfigMap', 'getVersion']);
|
||||
});
|
||||
|
||||
test('keeps standard glass on the ultra tier it became, once', () async {
|
||||
Future<LiquidGlassMode> upgrade(String mode, int from) async {
|
||||
final configMap = _createConfigMap();
|
||||
(configMap['themeProps']! as Map<String, Object?>)['liquidGlass'] =
|
||||
mode;
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: configMap,
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: from,
|
||||
);
|
||||
return (await Migration(store: store).run()).themeProps.liquidGlass;
|
||||
}
|
||||
|
||||
expect(await upgrade('standard', 1), LiquidGlassMode.ultra);
|
||||
expect(await upgrade('none', 1), LiquidGlassMode.none);
|
||||
expect(await upgrade('standard', 2), LiquidGlassMode.standard);
|
||||
});
|
||||
|
||||
test('a new install only records it', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: null,
|
||||
version: 0,
|
||||
upgradeVersion: 0,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
expect(config.themeProps, defaultThemeProps);
|
||||
expect(store.upgradeVersion, Migration.upgradeVersion);
|
||||
expect(
|
||||
store.events.where((event) => event == 'saveConfig'),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('waits for the next launch when saving fails', () async {
|
||||
final store = _FakeMigrationStore(
|
||||
configMap: customized(),
|
||||
version: Migration.currentVersion,
|
||||
upgradeVersion: 0,
|
||||
configSaveResult: false,
|
||||
);
|
||||
|
||||
final config = await Migration(store: store).run();
|
||||
|
||||
expect(config.themeProps.primaryColor, 0xFF123456);
|
||||
expect(store.upgradeVersion, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps what it can read of a damaged current config', () async {
|
||||
final configMap = _createConfigMap()
|
||||
..['currentProfileId'] = 7
|
||||
@@ -242,6 +349,7 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
final List<String> events = [];
|
||||
|
||||
int version;
|
||||
int upgradeVersion;
|
||||
Config? savedConfig;
|
||||
MigrationData? restoredData;
|
||||
bool didClearClashConfig = false;
|
||||
@@ -249,6 +357,7 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
_FakeMigrationStore({
|
||||
required this.configMap,
|
||||
required this.version,
|
||||
this.upgradeVersion = Migration.upgradeVersion,
|
||||
this.clashConfigMap,
|
||||
this.configSaveResult = true,
|
||||
this.available = true,
|
||||
@@ -302,4 +411,13 @@ class _FakeMigrationStore implements MigrationStore {
|
||||
events.add('setVersion');
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getUpgradeVersion() async => upgradeVersion;
|
||||
|
||||
@override
|
||||
Future<void> setUpgradeVersion(int version) async {
|
||||
events.add('setUpgradeVersion');
|
||||
upgradeVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ class TestApp extends StatelessWidget {
|
||||
final List<Override> overrides;
|
||||
final Widget Function(Widget child) homeBuilder;
|
||||
final Locale? locale;
|
||||
final ThemeData? theme;
|
||||
final ThemeData? darkTheme;
|
||||
final ThemeMode? themeMode;
|
||||
|
||||
const TestApp({
|
||||
super.key,
|
||||
@@ -26,6 +29,9 @@ class TestApp extends StatelessWidget {
|
||||
this.overrides = const [],
|
||||
this.homeBuilder = _identity,
|
||||
this.locale,
|
||||
this.theme,
|
||||
this.darkTheme,
|
||||
this.themeMode,
|
||||
});
|
||||
|
||||
static Widget _identity(Widget child) => child;
|
||||
@@ -35,6 +41,9 @@ class TestApp extends StatelessWidget {
|
||||
final app = MaterialApp(
|
||||
navigatorKey: includeNavigatorKey ? globalState.navigatorKey : null,
|
||||
locale: locale,
|
||||
theme: theme,
|
||||
darkTheme: darkTheme,
|
||||
themeMode: themeMode,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
AppUiLocalizations.delegate,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:fl_clash/manager/theme_manager.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
@@ -30,4 +32,37 @@ void main() {
|
||||
|
||||
expect(scaled, closeTo(13, 1e-9));
|
||||
});
|
||||
|
||||
testWidgets('a theme setting the theme does not read keeps the same theme', (
|
||||
tester,
|
||||
) async {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final themes = <ThemeData>[];
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: Consumer(
|
||||
builder: (_, ref, _) {
|
||||
ref.watch(themeSettingProvider);
|
||||
themes.add(watchAppTheme(ref, Brightness.dark));
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
void update(ThemeProps Function(ThemeProps props) change) {
|
||||
final notifier = container.read(themeSettingProvider.notifier);
|
||||
notifier.value = change(notifier.value);
|
||||
}
|
||||
|
||||
update((props) => props.copyWith(sidebarBlur: !props.sidebarBlur));
|
||||
await tester.pump();
|
||||
update((props) => props.copyWith(pureBlack: !props.pureBlack));
|
||||
await tester.pump();
|
||||
|
||||
expect(themes, hasLength(3));
|
||||
expect(themes[1], same(themes[0]));
|
||||
expect(themes[2], isNot(same(themes[1])));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
@@ -716,6 +717,42 @@ void main() {
|
||||
expect(restored.pureBlack, true);
|
||||
expect(restored.textScale.scale, 1.5);
|
||||
});
|
||||
|
||||
test('reads an old scheme variant as the nearest color style', () {
|
||||
ColorStyle styleOf(String variant) =>
|
||||
ThemeProps.fromJson({'schemeVariant': variant}).colorStyle;
|
||||
|
||||
expect(styleOf('content'), ColorStyle.vivid);
|
||||
expect(styleOf('fidelity'), ColorStyle.vivid);
|
||||
expect(styleOf('vibrant'), ColorStyle.vivid);
|
||||
expect(styleOf('tonalSpot'), ColorStyle.soft);
|
||||
expect(styleOf('neutral'), ColorStyle.soft);
|
||||
expect(styleOf('monochrome'), ColorStyle.monochrome);
|
||||
expect(
|
||||
ThemeProps.fromJson({
|
||||
'schemeVariant': 'monochrome',
|
||||
'colorStyle': 'tinted',
|
||||
}).colorStyle,
|
||||
ColorStyle.tinted,
|
||||
);
|
||||
expect(const ThemeProps().toJson(), isNot(contains('schemeVariant')));
|
||||
});
|
||||
|
||||
test('reads the old liquid glass switch as the standard tier', () {
|
||||
LiquidGlassMode modeOf(Object? value) =>
|
||||
ThemeProps.fromJson({'liquidGlass': value}).liquidGlass;
|
||||
|
||||
expect(modeOf(null), LiquidGlassMode.none);
|
||||
expect(modeOf(true), LiquidGlassMode.standard);
|
||||
expect(modeOf(false), LiquidGlassMode.none);
|
||||
expect(
|
||||
roundTrip(
|
||||
const ThemeProps(liquidGlass: LiquidGlassMode.standard).toJson,
|
||||
ThemeProps.fromJson,
|
||||
).liquidGlass,
|
||||
LiquidGlassMode.standard,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AccessControlProps', () {
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/database.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/ui/color.dart';
|
||||
import 'package:fl_clash/views/navigation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -760,10 +761,7 @@ void main() {
|
||||
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update(
|
||||
(state) =>
|
||||
state.copyWith(schemeVariant: DynamicSchemeVariant.tonalSpot),
|
||||
);
|
||||
.update((state) => state.copyWith(colorStyle: ColorStyle.soft));
|
||||
expect(container.read(provider), isNot(same(first)));
|
||||
});
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ void main() {
|
||||
accentColor: const Color(defaultPrimaryColor),
|
||||
);
|
||||
|
||||
final variant = container.read(themeSettingProvider).schemeVariant;
|
||||
final style = container.read(themeSettingProvider).colorStyle;
|
||||
|
||||
expect(
|
||||
container.read(genColorSchemeProvider(Brightness.light)),
|
||||
seededColorScheme(
|
||||
seedColor: lightSeed,
|
||||
brightness: Brightness.light,
|
||||
variant: variant,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
@@ -47,7 +47,7 @@ void main() {
|
||||
seededColorScheme(
|
||||
seedColor: darkSeed,
|
||||
brightness: Brightness.dark,
|
||||
variant: variant,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -91,7 +91,7 @@ void main() {
|
||||
seededColorScheme(
|
||||
seedColor: accent,
|
||||
brightness: Brightness.light,
|
||||
variant: container.read(themeSettingProvider).schemeVariant,
|
||||
style: container.read(themeSettingProvider).colorStyle,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
+202
-28
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/constant.dart';
|
||||
import 'package:fl_clash/common/feature.dart';
|
||||
import 'package:fl_clash/common/system_fonts.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
@@ -76,25 +78,171 @@ void main() {
|
||||
});
|
||||
|
||||
group('primary color', () {
|
||||
testWidgets('blocks the back gesture only while a color is removable', (
|
||||
Finder swatch(int color) => find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == Color(color).hex,
|
||||
);
|
||||
|
||||
testWidgets('starts on blue among the system colors', (tester) async {
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((_) => defaultThemeProps);
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readTheme().primaryColor, 0xFF007AFF);
|
||||
for (final color in defaultPrimaryColors) {
|
||||
expect(swatch(color), findsOneWidget);
|
||||
}
|
||||
expect(find.byTooltip('Reset'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('picks a color and the style of its scheme', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(swatch(0xFFAF52DE));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().primaryColor, 0xFFAF52DE);
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == 'Follow system',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().primaryColor, isNull);
|
||||
|
||||
await tester.tap(find.text('Color style'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Soft').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().colorStyle, ColorStyle.soft);
|
||||
});
|
||||
|
||||
testWidgets('deletes a color from its context menu', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
await tester.tap(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.longPress(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFFF9500)));
|
||||
expect(readTheme().primaryColor, defaultPrimaryColor);
|
||||
expect(swatch(0xFFFF9500), findsNothing);
|
||||
});
|
||||
|
||||
Future<void> editColor(WidgetTester tester, int color, String hex) async {
|
||||
await tester.longPress(swatch(color));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Edit'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(HexColorField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
hex,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('edits a color in place from its context menu', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
await tester.tap(swatch(0xFFFF9500));
|
||||
await tester.pumpAndSettle();
|
||||
final index = readTheme().primaryColors.indexOf(0xFFFF9500);
|
||||
|
||||
await editColor(tester, 0xFFFF9500, '123456');
|
||||
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFFF9500)));
|
||||
expect(readTheme().primaryColors[index], 0xFF123456);
|
||||
expect(readTheme().primaryColor, 0xFF123456);
|
||||
expect(swatch(0xFF123456), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('merges a color edited into one already listed', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpThemeView(tester);
|
||||
RoutePopDisposition disposition() {
|
||||
return ModalRoute.of(
|
||||
tester.element(find.byType(ThemeView)),
|
||||
)!.popDisposition;
|
||||
await tester.tap(swatch(0xFF007AFF));
|
||||
await tester.pumpAndSettle();
|
||||
final count = readTheme().primaryColors.length;
|
||||
|
||||
await editColor(tester, 0xFFAF52DE, '007AFF');
|
||||
|
||||
expect(readTheme().primaryColors, hasLength(count - 1));
|
||||
expect(readTheme().primaryColors, isNot(contains(0xFFAF52DE)));
|
||||
expect(readTheme().primaryColor, 0xFF007AFF);
|
||||
expect(swatch(0xFF007AFF), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('adds a custom color once and selects it', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
final count = readTheme().primaryColors.length;
|
||||
|
||||
for (var i = 0; i < 2; i++) {
|
||||
await tester.tap(find.byTooltip('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(HexColorField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'123456',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
expect(readTheme().primaryColors, hasLength(count + 1));
|
||||
expect(readTheme().primaryColors.last, 0xFF123456);
|
||||
expect(readTheme().primaryColor, 0xFF123456);
|
||||
expect(swatch(0xFF123456), findsOneWidget);
|
||||
});
|
||||
|
||||
await tester.longPress(find.byType(ColorSchemeBox).at(1));
|
||||
testWidgets('keeps the custom color palette clear of the edge back '
|
||||
'swipe', (tester) async {
|
||||
const size = Size(400, 860);
|
||||
container.read(viewSizeProvider.notifier).value = size;
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.systemGestureInsets = const FakeViewPadding(
|
||||
left: 30,
|
||||
right: 30,
|
||||
);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const TestApp(child: ThemeView()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), RoutePopDisposition.doNotPop);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.scrollUntilVisible(
|
||||
find.byTooltip('Add'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
|
||||
final pad = tester.getRect(
|
||||
find.descendant(
|
||||
of: find.byType(Palette),
|
||||
matching: find.byType(AspectRatio),
|
||||
),
|
||||
);
|
||||
expect(pad.left, 30);
|
||||
expect(pad.right, size.width - 30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,24 +275,26 @@ void main() {
|
||||
});
|
||||
|
||||
group('liquid glass', () {
|
||||
testWidgets('is off until chosen, and switches back', (tester) async {
|
||||
testWidgets('is off until a tier is chosen, and switches back', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpThemeView(tester);
|
||||
final group = find.ancestor(
|
||||
of: find.text('Liquid Glass').first,
|
||||
matching: find.byType(PreviewChoiceGroup<bool>),
|
||||
matching: find.byType(PreviewChoiceGroup<LiquidGlassMode>),
|
||||
);
|
||||
Finder choice(String label) =>
|
||||
find.descendant(of: group, matching: find.text(label)).last;
|
||||
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
|
||||
await tester.tap(choice('Liquid Glass'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isTrue);
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.none);
|
||||
|
||||
await tester.tap(choice('Standard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, isFalse);
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.standard);
|
||||
|
||||
await tester.tap(choice('None'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().liquidGlass, LiquidGlassMode.none);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +384,7 @@ void main() {
|
||||
String? previewFamilyOf(WidgetTester tester, String text) =>
|
||||
DefaultTextStyle.of(tester.element(inPage(text))).style.fontFamily;
|
||||
|
||||
Future<void> pumpPastArrivalHold(WidgetTester tester) async {
|
||||
Future<void> pumpSettled(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -242,7 +392,7 @@ void main() {
|
||||
Future<void> openFonts(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pumpAndSettle();
|
||||
await pumpPastArrivalHold(tester);
|
||||
await pumpSettled(tester);
|
||||
}
|
||||
|
||||
testWidgets('picks an installed font on its page and back to default', (
|
||||
@@ -271,26 +421,50 @@ void main() {
|
||||
expect(readTheme().fontFamily, isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows the loading illustration until the sheet has opened', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('lists the fonts as the sheet opens and gives the names '
|
||||
'their fonts once it has stopped', (tester) async {
|
||||
var reads = 0;
|
||||
systemFontFamiliesReader = () async {
|
||||
reads++;
|
||||
return ['Alpha Sans'];
|
||||
return ['Arrival Sans'];
|
||||
};
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.byType(FontFamilyView), findsOneWidget);
|
||||
expect(reads, 0);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(reads, 1);
|
||||
expect(fontsIllustration, findsNothing);
|
||||
expect(inPage('Arrival Sans'), findsOneWidget);
|
||||
expect(
|
||||
isRouteMoving(tester.element(find.byType(FontFamilyView))),
|
||||
isTrue,
|
||||
);
|
||||
expect(previewFamilyOf(tester, 'Arrival Sans'), isNot('Arrival Sans'));
|
||||
|
||||
await pumpSettled(tester);
|
||||
expect(previewFamilyOf(tester, 'Arrival Sans'), 'Arrival Sans');
|
||||
});
|
||||
|
||||
testWidgets('shows the loading illustration while the fonts take long', (
|
||||
tester,
|
||||
) async {
|
||||
final families = Completer<List<String>>();
|
||||
systemFontFamiliesReader = () => families.future;
|
||||
await pumpThemeView(tester);
|
||||
|
||||
await tester.tap(find.text('Aa').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(fontsIllustration, findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(fontsIllustration, findsOneWidget);
|
||||
expect(inPage('Default'), findsNothing);
|
||||
|
||||
await pumpPastArrivalHold(tester);
|
||||
expect(reads, 1);
|
||||
families.complete(['Alpha Sans']);
|
||||
await pumpSettled(tester);
|
||||
expect(inPage('Alpha Sans'), findsOneWidget);
|
||||
expect(fontsIllustration, findsNothing);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:fl_clash/ui/sheet.dart';
|
||||
import 'package:fl_clash/ui/sheet_header.dart';
|
||||
import 'package:fl_clash/ui/snap_sheet.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -154,6 +155,75 @@ void main() {
|
||||
expect(sheetTop(tester), closeTo(open, 1));
|
||||
});
|
||||
|
||||
testWidgets('a flick up from the short detent rises to the height of '
|
||||
'content a little taller than it without dropping on the way', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
|
||||
await tester.fling(content(), const Offset(0, -100), 3000);
|
||||
var top = sheetTop(tester);
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
|
||||
top = sheetTop(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), greaterThan(_tallTop + 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a drag let go nearer the height of content a little taller '
|
||||
'than the short detent than the detent opens the sheet to it', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
final short = sheetTop(tester);
|
||||
|
||||
final gesture = await tester.startGesture(tester.getCenter(content()));
|
||||
for (var move = 0; move < 6; move++) {
|
||||
await gesture.moveBy(const Offset(0, -20));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), lessThan(short - 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a wheel tick opens a sheet a little taller than the short '
|
||||
'detent to its content height without dropping on the way', (
|
||||
tester,
|
||||
) async {
|
||||
await openSheet(tester, (_) => _list(14));
|
||||
final pointer = TestPointer(1, PointerDeviceKind.mouse);
|
||||
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
|
||||
|
||||
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
|
||||
var top = sheetTop(tester);
|
||||
for (var frame = 0; frame < 40; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
|
||||
top = sheetTop(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sheetTop(tester), greaterThan(_tallTop + 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('row 13')).dy,
|
||||
lessThanOrEqualTo(_view.height),
|
||||
);
|
||||
});
|
||||
|
||||
double rowBottom(WidgetTester tester, int row) =>
|
||||
tester.getBottomLeft(find.widgetWithText(ListTile, 'row $row')).dy;
|
||||
|
||||
@@ -211,21 +281,128 @@ void main() {
|
||||
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
|
||||
});
|
||||
|
||||
testWidgets('a long sheet at the tall detent rises with the keyboard and '
|
||||
'comes back down with it on every frame', (tester) async {
|
||||
testWidgets('a long sheet at the tall detent rises with the keyboard into '
|
||||
'the peek and comes back down with it on every frame', (tester) async {
|
||||
await openSheet(tester, (_) => _list(60));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.fling(content(), const Offset(0, -200), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sheetTop(tester), closeTo(_tallTop, 1));
|
||||
|
||||
for (final inset in [40.0, 120.0, 300.0, 200.0, 60.0, 20.0, 0.0]) {
|
||||
var top = _tallTop;
|
||||
var last = 0.0;
|
||||
for (final inset in [40.0, 120.0, 300.0, 120.0, 40.0]) {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: inset);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final moved = sheetTop(tester);
|
||||
expect(
|
||||
sheetTop(tester),
|
||||
closeTo(math.max(stackedSheetPeek, _tallTop - inset), 1),
|
||||
moved,
|
||||
inInclusiveRange(
|
||||
math.max(stackedSheetPeek, _tallTop - inset) - 1,
|
||||
_tallTop - 1,
|
||||
),
|
||||
);
|
||||
expect(inset > last ? top - moved : moved - top, greaterThan(1));
|
||||
top = moved;
|
||||
last = inset;
|
||||
}
|
||||
expect(top, greaterThan(stackedSheetPeek + 1));
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
|
||||
tester.view.resetViewInsets();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(sheetTop(tester), closeTo(_tallTop, 1));
|
||||
});
|
||||
|
||||
Future<List<double>> trackKeyboard(
|
||||
WidgetTester tester,
|
||||
Iterable<double> insets,
|
||||
) async {
|
||||
final tops = <double>[];
|
||||
for (final inset in insets) {
|
||||
tester.view.viewInsets = FakeViewPadding(bottom: inset);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
tops.add(sheetTop(tester));
|
||||
}
|
||||
return tops;
|
||||
}
|
||||
|
||||
/// A keyboard moving at a steady speed never jolts the sheet: its speed
|
||||
/// changes by a small part of the keyboard's from one frame to the next.
|
||||
void expectEased(List<double> tops, double step) {
|
||||
for (var index = 2; index < tops.length; index++) {
|
||||
final speed = tops[index] - tops[index - 1];
|
||||
final before = tops[index - 1] - tops[index - 2];
|
||||
expect((speed - before).abs(), lessThan(step / 4));
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('a short sheet starts and stops rising with the keyboard and '
|
||||
'keeps its content above it', (tester) async {
|
||||
await openSheet(tester, (_) => _list(3));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
final rest = sheetTop(tester);
|
||||
const step = 10.0;
|
||||
final insets = [for (var inset = 0.0; inset <= 400; inset += step) inset];
|
||||
|
||||
final rising = await trackKeyboard(tester, insets);
|
||||
expect(rising.last, lessThan(rest - 1));
|
||||
expect(rowBottom(tester, 2), lessThanOrEqualTo(_view.height - 400));
|
||||
expectEased(rising, step);
|
||||
|
||||
final falling = await trackKeyboard(tester, insets.reversed);
|
||||
expectEased(falling, step);
|
||||
expect(falling.last, closeTo(rest, 0.01));
|
||||
});
|
||||
|
||||
testWidgets('a long sheet at the tall detent slows into the peek with the '
|
||||
'keyboard', (tester) async {
|
||||
await openSheet(tester, (_) => _list(60));
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
await tester.fling(content(), const Offset(0, -200), 2000);
|
||||
await tester.pumpAndSettle();
|
||||
const step = 8.0;
|
||||
final insets = [for (var inset = 0.0; inset <= 240; inset += step) inset];
|
||||
|
||||
final tops = await trackKeyboard(tester, insets);
|
||||
expectEased(tops, step);
|
||||
expect(tops.last, closeTo(stackedSheetPeek, 1));
|
||||
});
|
||||
|
||||
testWidgets('the keyboard moves a sheet without laying out or painting its '
|
||||
'content again', (tester) async {
|
||||
for (final rows in [3, 60]) {
|
||||
final counts = _Counts();
|
||||
await openSheet(
|
||||
tester,
|
||||
(_) => CommonScaffold(
|
||||
title: 'title',
|
||||
body: _Counted(
|
||||
counts: counts,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (var index = 0; index < rows; index++)
|
||||
SizedBox(height: _rowHeight, child: Text('row $index')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(tester.view.resetViewInsets);
|
||||
counts
|
||||
..layouts = 0
|
||||
..paints = 0;
|
||||
|
||||
final tops = await trackKeyboard(tester, [100, 200, 300, 200, 100, 0]);
|
||||
expect(tops.toSet(), hasLength(greaterThan(2)), reason: '$rows rows');
|
||||
expect(counts.layouts, 0, reason: '$rows rows');
|
||||
expect(counts.paints, 0, reason: '$rows rows');
|
||||
|
||||
Navigator.of(tester.element(find.text('row 0'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -530,3 +707,36 @@ void main() {
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
|
||||
);
|
||||
}
|
||||
|
||||
class _Counts {
|
||||
var layouts = 0;
|
||||
var paints = 0;
|
||||
}
|
||||
|
||||
class _Counted extends SingleChildRenderObjectWidget {
|
||||
const _Counted({required this.counts, required super.child});
|
||||
|
||||
final _Counts counts;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_RenderCounted(counts);
|
||||
}
|
||||
|
||||
class _RenderCounted extends RenderProxyBox {
|
||||
_RenderCounted(this.counts);
|
||||
|
||||
final _Counts counts;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
counts.layouts++;
|
||||
super.performLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
counts.paints++;
|
||||
super.paint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/ui/icons/icons.dart';
|
||||
import 'package:fl_clash/views/theme.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/glyph_finders.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void _noop() {}
|
||||
@@ -15,16 +13,30 @@ const _width = 520.0;
|
||||
List<Widget> _actions() => const [
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Delete',
|
||||
onPressed: _noop,
|
||||
icon: GlyphIcon(AppGlyphs.delete, fill: 1),
|
||||
),
|
||||
),
|
||||
CommonMinFilledButtonTheme(
|
||||
child: FilledButton(onPressed: _noop, child: Text('Select all')),
|
||||
CommonMinIconButtonTheme(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'Select all',
|
||||
onPressed: _noop,
|
||||
icon: GlyphIcon(AppGlyphs.selectAll),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
const _data = [
|
||||
IconButtonData(glyph: AppGlyphs.delete, tooltip: 'Delete', onPressed: _noop),
|
||||
IconButtonData(
|
||||
glyph: AppGlyphs.selectAll,
|
||||
tooltip: 'Select all',
|
||||
onPressed: _noop,
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
tester.view.physicalSize = const Size(_width, 200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -40,49 +52,29 @@ Future<Rect> _pumpHeader(WidgetTester tester, Widget header) async {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return tester.getRect(find.byType(FilledButton));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('both headers end their actions at the same inset', (
|
||||
tester,
|
||||
) async {
|
||||
final list = await _pumpHeader(
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
ListHeader(title: 'Exclude SSIDs', actions: _actions()),
|
||||
const ListHeader(title: 'Exclude SSIDs', actions: _data),
|
||||
);
|
||||
final info = await _pumpHeader(
|
||||
final list = tester.getRect(find.byTooltip('Select all'));
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Added rules'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final info = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(_width - list.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a card header ends where a plain one does', (tester) async {
|
||||
final info = await _pumpHeader(
|
||||
tester,
|
||||
InfoHeader(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final card = await _pumpHeader(
|
||||
tester,
|
||||
ItemCard(
|
||||
info: const Info(label: 'Theme color'),
|
||||
actions: _actions(),
|
||||
space: 8,
|
||||
child: const SizedBox(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_width - card.right, _width - info.right);
|
||||
});
|
||||
|
||||
testWidgets('a header spaces its actions like an app bar', (tester) async {
|
||||
await _pumpHeader(
|
||||
tester,
|
||||
@@ -91,11 +83,29 @@ void main() {
|
||||
actions: _actions(),
|
||||
),
|
||||
);
|
||||
final icon = tester.getRect(
|
||||
find.widgetWithGlyph(IconButton, AppGlyphs.delete),
|
||||
);
|
||||
final pill = tester.getRect(find.byType(FilledButton));
|
||||
final first = tester.getRect(find.byTooltip('Delete'));
|
||||
final second = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(pill.left - icon.right, appBarActionSpace);
|
||||
expect(second.left - first.right, appBarActionSpace);
|
||||
});
|
||||
|
||||
testWidgets('a list header stands as tall with actions as without', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpHeader(tester, const ListHeader(title: 'Rules'));
|
||||
final bare = tester.getRect(find.text('Rules'));
|
||||
final bareHeader = tester.getSize(find.byType(ListHeader));
|
||||
await _pumpHeader(tester, const ListHeader(title: 'Rules', actions: _data));
|
||||
final withActions = tester.getRect(find.text('Rules'));
|
||||
final button = tester.getRect(find.byTooltip('Select all'));
|
||||
|
||||
expect(withActions.topLeft, bare.topLeft);
|
||||
expect(tester.getSize(find.byType(ListHeader)).height, bareHeader.height);
|
||||
expect(button.height, TonalButtonSize.section.button);
|
||||
expect(button.center.dy, closeTo(withActions.center.dy, 1));
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(ListHeader)).dy - button.bottom,
|
||||
greaterThanOrEqualTo(6),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+126
-42
@@ -5,64 +5,148 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Palette updates color from hue, chroma, and tone controls', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = ValueNotifier<Color>(Colors.blue);
|
||||
addTearDown(controller.dispose);
|
||||
late ValueNotifier<Color> controller;
|
||||
|
||||
await tester.pumpWidget(
|
||||
setUp(() => controller = ValueNotifier(const Color(0xFF007AFF)));
|
||||
tearDown(() => controller.dispose());
|
||||
|
||||
Future<void> pumpPalette(WidgetTester tester, {ScrollController? scroll}) {
|
||||
return tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SizedBox(width: 700, child: Palette(controller: controller)),
|
||||
body: ListView(
|
||||
controller: scroll,
|
||||
children: [
|
||||
SizedBox(width: 360, child: Palette(controller: controller)),
|
||||
const SizedBox(height: 1200),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.byType(Slider), findsNWidgets(2));
|
||||
expect(find.text('Preview'), findsOneWidget);
|
||||
expect(find.text('Primary'), findsOneWidget);
|
||||
expect(find.text('100'), findsOneWidget);
|
||||
final pad = find.descendant(
|
||||
of: find.byType(Palette),
|
||||
matching: find.byType(AspectRatio),
|
||||
);
|
||||
|
||||
final initial = controller.value;
|
||||
tester.widget<Slider>(find.byType(Slider).first).onChanged!(180);
|
||||
await tester.pump();
|
||||
expect(controller.value, isNot(initial));
|
||||
Offset at(WidgetTester tester, double x, double y) {
|
||||
final rect = tester.getRect(pad);
|
||||
return rect.topLeft + Offset(rect.width * x, rect.height * y);
|
||||
}
|
||||
|
||||
final hueColor = controller.value;
|
||||
tester.widget<Slider>(find.byType(Slider).last).onChanged!(8);
|
||||
await tester.pump();
|
||||
expect(controller.value, isNot(hueColor));
|
||||
testWidgets('the grid picks the cell under the finger', (tester) async {
|
||||
await pumpPalette(tester);
|
||||
|
||||
await tester.tap(find.text('0'));
|
||||
await tester.pump();
|
||||
expect(controller.value.computeLuminance(), lessThan(0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tapAt(at(tester, 0.02, 0.02));
|
||||
expect(controller.value, const Color(0xFFFFFFFF));
|
||||
|
||||
await tester.tapAt(at(tester, 0.98, 0.02));
|
||||
expect(controller.value, const Color(0xFF000000));
|
||||
|
||||
final hues = <double>{};
|
||||
final gesture = await tester.startGesture(at(tester, 0.04, 0.5));
|
||||
for (var x = 0.04; x < 1; x += 0.0833) {
|
||||
await gesture.moveTo(at(tester, x, 0.5));
|
||||
hues.add(HSVColor.fromColor(controller.value).hue.roundToDouble());
|
||||
}
|
||||
await gesture.up();
|
||||
expect(hues.length, 12);
|
||||
});
|
||||
|
||||
testWidgets('Palette lays out narrow tone and preview grids', (tester) async {
|
||||
final controller = ValueNotifier<Color>(Colors.white);
|
||||
addTearDown(controller.dispose);
|
||||
testWidgets('the spectrum runs hue across and lightness down, and keeps '
|
||||
'the drag from scrolling', (tester) async {
|
||||
final scroll = ScrollController();
|
||||
addTearDown(scroll.dispose);
|
||||
await pumpPalette(tester, scroll: scroll);
|
||||
await tester.tap(find.text('Spectrum'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
child: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: SizedBox(width: 32, child: Palette(controller: controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tapAt(at(tester, 0.5, 0.5));
|
||||
final middle = HSLColor.fromColor(controller.value);
|
||||
expect(middle.hue, closeTo(180, 2));
|
||||
expect(middle.lightness, closeTo(0.5, 0.02));
|
||||
|
||||
final gesture = await tester.startGesture(at(tester, 0.5, 0.5));
|
||||
await gesture.moveBy(const Offset(0, -400));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
|
||||
expect(scroll.offset, 0);
|
||||
expect(controller.value, const Color(0xFFFFFFFF));
|
||||
});
|
||||
|
||||
testWidgets('the sliders keep the hue a gray drops', (tester) async {
|
||||
await pumpPalette(tester);
|
||||
await tester.tap(find.text('Sliders'));
|
||||
await tester.pumpAndSettle();
|
||||
Slider slider(int index) =>
|
||||
tester.widget<Slider>(find.byType(Slider).at(index));
|
||||
final hue = slider(0).value;
|
||||
|
||||
slider(1).onChanged!(0);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('100'), findsOneWidget);
|
||||
expect(find.text('Primary'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(HSVColor.fromColor(controller.value).saturation, 0);
|
||||
expect(slider(0).value, hue);
|
||||
|
||||
slider(1).onChanged!(1);
|
||||
await tester.pump();
|
||||
slider(2).onChanged!(0.5);
|
||||
await tester.pump();
|
||||
|
||||
final picked = HSVColor.fromColor(controller.value);
|
||||
expect(picked.hue, closeTo(hue, 1));
|
||||
expect(picked.value, closeTo(0.5, 0.01));
|
||||
expect(find.text('50%'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('hex field', () {
|
||||
Future<void> pumpField(WidgetTester tester) {
|
||||
return tester.pumpWidget(
|
||||
TestApp(
|
||||
child: Scaffold(
|
||||
body: ValueListenableBuilder(
|
||||
valueListenable: controller,
|
||||
builder: (_, color, _) => HexColorField(
|
||||
color: color,
|
||||
onChanged: (color) => controller.value = color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String text(WidgetTester tester) =>
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text;
|
||||
|
||||
testWidgets('applies six digits and leaves a partial one alone', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpField(tester);
|
||||
expect(text(tester), '007AFF');
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ff3');
|
||||
await tester.pump();
|
||||
expect(controller.value, const Color(0xFF007AFF));
|
||||
expect(text(tester), 'FF3');
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ff3b30');
|
||||
await tester.pump();
|
||||
expect(controller.value, const Color(0xFFFF3B30));
|
||||
expect(text(tester), 'FF3B30');
|
||||
});
|
||||
|
||||
testWidgets('follows a color picked elsewhere', (tester) async {
|
||||
await pumpField(tester);
|
||||
|
||||
controller.value = const Color(0xFF34C759);
|
||||
await tester.pump();
|
||||
|
||||
expect(text(tester), '34C759');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:fl_clash/providers/app.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../helpers/test_app.dart';
|
||||
|
||||
ThemeData _theme(Brightness brightness) => ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.blue,
|
||||
brightness: brightness,
|
||||
),
|
||||
);
|
||||
|
||||
Future<ValueNotifier<ThemeMode>> _openFromPage(
|
||||
WidgetTester tester, {
|
||||
required Size size,
|
||||
required void Function(BuildContext context) open,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final mode = ValueNotifier(ThemeMode.light);
|
||||
addTearDown(mode.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: mode,
|
||||
builder: (_, themeMode, _) => TestApp(
|
||||
theme: _theme(Brightness.light),
|
||||
darkTheme: _theme(Brightness.dark),
|
||||
themeMode: themeMode,
|
||||
child: CommonScaffold(
|
||||
title: 'Page',
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => open(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
return mode;
|
||||
}
|
||||
|
||||
Color? _sheetColor(WidgetTester tester) => tester
|
||||
.widget<Material>(
|
||||
find
|
||||
.ancestor(of: find.text('sheet'), matching: find.byType(Material))
|
||||
.first,
|
||||
)
|
||||
.color;
|
||||
|
||||
void main() {
|
||||
final darkSheetColor = SurfaceLevel.page.colorOf(
|
||||
_theme(Brightness.dark).colorScheme,
|
||||
);
|
||||
|
||||
for (final (kind, size, type, show) in [
|
||||
('bottom sheet', const Size(400, 800), SheetType.bottomSheet, showSheet),
|
||||
('side sheet', const Size(1200, 800), SheetType.sideSheet, showSheet),
|
||||
('form sheet', const Size(1200, 800), SheetType.formSheet, showFormSheet),
|
||||
]) {
|
||||
testWidgets('an open $kind takes a new app theme', (tester) async {
|
||||
final mode = await _openFromPage(
|
||||
tester,
|
||||
size: size,
|
||||
open: (context) =>
|
||||
show<void>(context: context, builder: (_) => const Text('sheet')),
|
||||
);
|
||||
expect(SheetProvider.of(tester.element(find.text('sheet')))?.type, type);
|
||||
expect(_sheetColor(tester), isNot(darkSheetColor));
|
||||
|
||||
mode.value = ThemeMode.dark;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_sheetColor(tester), darkSheetColor);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -863,7 +863,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
TestApp(
|
||||
wrapInProviderScope: true,
|
||||
homeBuilder: (child) => Theme(data: theme, child: child),
|
||||
theme: theme,
|
||||
child: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showSnapSheet<void>(context, builder: _sheet),
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:fl_clash/ui/theme_cross_fade.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _red = Color(0xFFFF0000);
|
||||
const _blue = Color(0xFF0000FF);
|
||||
const _green = Color(0xFF00FF00);
|
||||
|
||||
void main() {
|
||||
final shotKey = GlobalKey();
|
||||
late ValueNotifier<Color> background;
|
||||
|
||||
setUp(() => background = ValueNotifier(_red));
|
||||
tearDown(() => background.dispose());
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester, {double opacity = 1}) async {
|
||||
tester.view.physicalSize = const Size(40, 40);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: background,
|
||||
builder: (_, color, _) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(scaffoldBackgroundColor: color),
|
||||
themeAnimationStyle: AnimationStyle.noAnimation,
|
||||
builder: (_, child) => ThemeCrossFade(child: child!),
|
||||
home: Builder(
|
||||
builder: (context) => ColoredBox(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor.withValues(alpha: opacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Color> pixel(WidgetTester tester) async {
|
||||
final boundary =
|
||||
shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
late Color color;
|
||||
await tester.runAsync(() async {
|
||||
final image = await boundary.toImage();
|
||||
final data = (await image.toByteData())!;
|
||||
image.dispose();
|
||||
color = Color.fromARGB(
|
||||
data.getUint8(3),
|
||||
data.getUint8(0),
|
||||
data.getUint8(1),
|
||||
data.getUint8(2),
|
||||
);
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
List<double> channels(Color color) => [color.a, color.r, color.g, color.b];
|
||||
|
||||
Matcher isColor(Color expected) => isA<Color>().having(
|
||||
channels,
|
||||
'channels',
|
||||
pairwiseCompare<double, double>(
|
||||
channels(expected),
|
||||
(expected, actual) => (expected - actual).abs() <= 2 / 255,
|
||||
'within 2/255 of',
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('shows the last frame over a new theme until the fade ends', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
expect(await pixel(tester), isColor(_red));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(await pixel(tester), isColor(Color.lerp(_red, _blue, 0.5)!));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(await pixel(tester), isColor(_blue));
|
||||
});
|
||||
|
||||
testWidgets('keeps a translucent pixel as opaque as it was', (tester) async {
|
||||
await pumpApp(tester, opacity: 0.5);
|
||||
final alpha = (await pixel(tester)).a;
|
||||
|
||||
background.value = _blue;
|
||||
for (var frame = 0; frame < 14; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(
|
||||
(await pixel(tester)).a,
|
||||
moreOrLessEquals(alpha, epsilon: 1 / 255),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a second change mid-fade starts from what is on screen', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final onScreen = await pixel(tester);
|
||||
|
||||
background.value = _green;
|
||||
await tester.pump();
|
||||
expect(await pixel(tester), isColor(onScreen));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(await pixel(tester), isColor(_green));
|
||||
});
|
||||
|
||||
testWidgets('switches at once when animations are disabled', (tester) async {
|
||||
tester.platformDispatcher.accessibilityFeaturesTestValue =
|
||||
const FakeAccessibilityFeatures(disableAnimations: true);
|
||||
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
|
||||
await pumpApp(tester);
|
||||
|
||||
background.value = _blue;
|
||||
await tester.pump();
|
||||
|
||||
expect(await pixel(tester), isColor(_blue));
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user