feat(ui): draw the app's own glyphs and redraw the theme page
Drops the Material compatibility bridge and the icon font. Changelog: New app glyphs, empty states and theme page
@@ -1,9 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="69" y="69" width="70" height="70" rx="22" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<rect x="161" y="69" width="70" height="70" rx="35" fill="#6750A4"/>
|
||||
<rect x="69" y="161" width="70" height="70" rx="35" fill="#6750A4" opacity="0.58"/>
|
||||
<path d="M174 161H218C225.18 161 231 166.82 231 174V218C231 225.18 225.18 231 218 231H174C166.82 231 161 225.18 161 218V174C161 166.82 166.82 161 174 161Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M90 104H118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M104 90V118" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M181 196H211" stroke="#6750A4" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 827 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M122 150H178" stroke="#6750A4" stroke-width="14" stroke-linecap="round"/>
|
||||
<circle cx="92" cy="150" r="34" fill="#FDF7FF" stroke="#6750A4" stroke-width="9"/>
|
||||
<circle cx="208" cy="150" r="34" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M82 108C82 90.33 112.45 76 150 76C187.55 76 218 90.33 218 108V197C218 214.67 187.55 229 150 229C112.45 229 82 214.67 82 197V108Z" fill="#6750A4"/>
|
||||
<ellipse cx="150" cy="108" rx="52" ry="17" fill="#FDF7FF"/>
|
||||
<path d="M105 161C128 170 172 170 195 161" stroke="#FDF7FF" stroke-width="9" stroke-linecap="round" opacity="0.72"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="150" cy="150" r="78" fill="#FDF7FF" stroke="#6750A4" stroke-width="12"/>
|
||||
<path d="M150 105V154L183 177" stroke="#6750A4" stroke-width="13" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 311 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="57" y="72" width="186" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M57 100C57 84.54 69.54 72 85 72H215C230.46 72 243 84.54 243 100V112H57V100Z" fill="#E8DEF8"/>
|
||||
<circle cx="84" cy="92" r="6" fill="#6750A4"/>
|
||||
<circle cx="104" cy="92" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M88 150L108 166L88 182" stroke="#6750A4" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M126 183H169" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="215" cy="204" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M215 186V204L226 214" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 838 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="91" y="126" width="118" height="92" rx="28" fill="#6750A4"/>
|
||||
<path d="M116 126V111C116 92 131 77 150 77C169 77 184 92 184 111V126" stroke="#6750A4" stroke-width="15" stroke-linecap="round"/>
|
||||
<circle cx="150" cy="169" r="10" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 363 B |
@@ -1,14 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M82 58H182L218 94V234C218 244 210 252 200 252H82C72 252 64 244 64 234V76C64 66 72 58 82 58Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M182 58V94H218" fill="#E8DEF8"/>
|
||||
<path d="M182 58V94H218" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M94 124H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="126" cy="124" r="12" fill="#6750A4"/>
|
||||
<path d="M94 164H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="166" cy="164" r="12" fill="#6750A4"/>
|
||||
<path d="M94 204H188" stroke="#6750A4" stroke-width="8" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="142" cy="204" r="12" fill="#6750A4"/>
|
||||
<circle cx="213" cy="218" r="33" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M193 220C204 207 222 207 233 220" stroke="#FDF7FF" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="213" cy="204" r="8" fill="#FDF7FF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M150 205V153M150 153L96 99M150 153L204 99" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="96" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="204" cy="99" r="27" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="150" cy="205" r="30" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="96" y="72" width="145" height="156" rx="28" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<circle cx="120" cy="100" r="6" fill="#6750A4"/>
|
||||
<circle cx="140" cy="100" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M58 158H190M171 139L192 158L171 177" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 462 B |
@@ -1,7 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 69H237L174 148V221L126 246V148L63 69Z" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M95 101H205" stroke="#6750A4" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M113 127H187" stroke="#6750A4" stroke-width="10" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="215" cy="205" r="34" fill="#6750A4" stroke="#FDF7FF" stroke-width="6"/>
|
||||
<path d="M199 205L210 216L231 193" stroke="#FDF7FF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 626 B |
@@ -1,9 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="54" y="65" width="192" height="170" rx="30" fill="#FDF7FF" stroke="#C4C7C5" stroke-width="3"/>
|
||||
<path d="M54 104H246" stroke="#E8DEF8" stroke-width="18"/>
|
||||
<circle cx="82" cy="84" r="6" fill="#6750A4"/>
|
||||
<circle cx="103" cy="84" r="6" fill="#6750A4" opacity="0.55"/>
|
||||
<path d="M112 137L82 166L112 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M188 137L218 166L188 195" stroke="#6750A4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M169 126L133 206" stroke="#6750A4" stroke-width="12" stroke-linecap="round" opacity="0.62"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 733 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89 133C123 102 177 102 211 133" stroke="#6750A4" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M119 172C136 157 164 157 181 172" stroke="#6750A4" stroke-width="18" stroke-linecap="round" opacity="0.62"/>
|
||||
<circle cx="150" cy="210" r="12" fill="#6750A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 383 B |
@@ -6,12 +6,14 @@ import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/common/window.dart';
|
||||
import 'package:fl_clash/bootstrap.dart';
|
||||
import 'package:fl_clash/common/system_dns.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/manager/hotkey_manager.dart';
|
||||
import 'package:fl_clash/manager/manager.dart';
|
||||
import 'package:fl_clash/plugins/app.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:fl_clash/widgets/focus.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -56,6 +58,17 @@ class Application extends ConsumerStatefulWidget {
|
||||
ConsumerState<Application> createState() => ApplicationState();
|
||||
}
|
||||
|
||||
const _actionIconTheme = ActionIconThemeData(
|
||||
backButtonIconBuilder: _backButtonIcon,
|
||||
closeButtonIconBuilder: _closeButtonIcon,
|
||||
);
|
||||
|
||||
Widget _backButtonIcon(BuildContext context) =>
|
||||
GlyphIcon(AppGlyphs.backFor(Theme.of(context).platform));
|
||||
|
||||
Widget _closeButtonIcon(BuildContext context) =>
|
||||
const GlyphIcon(AppGlyphs.close);
|
||||
|
||||
class ApplicationState extends ConsumerState<Application> {
|
||||
Timer? _autoUpdateProfilesTaskTimer;
|
||||
bool _preHasVpn = false;
|
||||
@@ -85,7 +98,9 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
}
|
||||
_autoUpdateProfilesTask();
|
||||
_initLink();
|
||||
unawaited(app?.initShortcuts());
|
||||
if (!safeModeBuild) {
|
||||
unawaited(app?.initShortcuts());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,7 +151,7 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
unawaited(ref.read(systemActionProvider.notifier).updateLocalIp());
|
||||
final hasVpn = results.contains(ConnectivityResult.vpn);
|
||||
if (_preHasVpn == hasVpn) {
|
||||
ref.read(checkIpNumProvider.notifier).add();
|
||||
ref.read(routeTrackerProvider.notifier).bumpHostEpoch();
|
||||
}
|
||||
_preHasVpn = hasVpn;
|
||||
}
|
||||
@@ -158,18 +173,10 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
builder: (context, child) {
|
||||
// The bridge's legacy Theme swaps in its own default IconTheme color,
|
||||
// which material_ui IconButton.filled reads as custom and loses onPrimary.
|
||||
// ignore: deprecated_member_use
|
||||
return MaterialUiCompatibilityBridge(
|
||||
child: IconTheme(
|
||||
data: Theme.of(context).iconTheme,
|
||||
child: buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
return buildManagerStack(
|
||||
isDesktop: system.isDesktop,
|
||||
onConnectivityChanged: _handleConnectivityChanged,
|
||||
child: RemoteFocusAdapter(enabled: system.isTV, child: child!),
|
||||
);
|
||||
},
|
||||
scrollBehavior: const BaseScrollBehavior(),
|
||||
@@ -180,14 +187,14 @@ class ApplicationState extends ConsumerState<Application> {
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
actionIconTheme: _actionIconTheme,
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.light),
|
||||
).withAppShapes,
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
colorScheme: _getAppColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
).toPureBlack(themeProps.pureBlack),
|
||||
actionIconTheme: _actionIconTheme,
|
||||
colorScheme: _getAppColorScheme(brightness: Brightness.dark),
|
||||
).withAppShapes,
|
||||
home: child!,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
extension ColorExtension on Color {
|
||||
@@ -84,15 +86,6 @@ extension ColorExtension on Color {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
Color darken([final int amount = 10]) {
|
||||
if (amount <= 0) return this;
|
||||
if (amount > 100) return Colors.black;
|
||||
final HSLColor hsl = HSLColor.fromColor(this);
|
||||
return hsl
|
||||
.withLightness(min(1, max(0, hsl.lightness - amount / 100)))
|
||||
.toColor();
|
||||
}
|
||||
|
||||
Color blendDarken(BuildContext context, {double factor = 0.1}) {
|
||||
final brightness = Theme.of(context).brightness;
|
||||
return Color.lerp(
|
||||
@@ -104,17 +97,40 @@ extension ColorExtension on Color {
|
||||
}
|
||||
|
||||
extension ColorSchemeExtension on ColorScheme {
|
||||
ColorScheme toPureBlack(bool isPrueBlack) => isPrueBlack
|
||||
? copyWith(
|
||||
surface: Colors.black,
|
||||
surfaceContainer: surfaceContainer.darken(5),
|
||||
)
|
||||
: this;
|
||||
}
|
||||
ColorScheme toPureBlack(bool isPureBlack) {
|
||||
if (!isPureBlack || brightness != Brightness.dark) {
|
||||
return this;
|
||||
}
|
||||
final shift = Hct.fromInt(surface.toARGB32()).tone;
|
||||
Color lower(Color color) {
|
||||
final hct = Hct.fromInt(color.toARGB32());
|
||||
return Color(
|
||||
Hct.from(hct.hue, hct.chroma, max(0, hct.tone - shift)).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
Color? getDelayColor(int? delay) {
|
||||
if (delay == null) return null;
|
||||
if (delay < 0) return Colors.red;
|
||||
if (delay < 600) return Colors.green;
|
||||
return const Color(0xFFC57F0A);
|
||||
return copyWith(
|
||||
surface: Colors.black,
|
||||
surfaceDim: Colors.black,
|
||||
surfaceContainerLowest: Colors.black,
|
||||
surfaceContainerLow: lower(surfaceContainerLow),
|
||||
surfaceContainer: lower(surfaceContainer),
|
||||
surfaceContainerHigh: lower(surfaceContainerHigh),
|
||||
surfaceContainerHighest: lower(surfaceContainerHighest),
|
||||
surfaceBright: lower(surfaceBright),
|
||||
);
|
||||
}
|
||||
|
||||
Color get modalScrim => scrim.withValues(alpha: 0.32);
|
||||
|
||||
Color get success => Colors.green.harmonizeWith(primary);
|
||||
|
||||
Color get warning => Colors.orange.harmonizeWith(primary);
|
||||
|
||||
Color? delayColor(int? delay) {
|
||||
if (delay == null) return null;
|
||||
if (delay < 0) return error;
|
||||
if (delay < 600) return success;
|
||||
return warning;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export 'fixed.dart';
|
||||
export 'function.dart';
|
||||
export 'future.dart';
|
||||
export 'http.dart';
|
||||
export 'icons.dart';
|
||||
export 'indexing.dart';
|
||||
export 'input_entries.dart';
|
||||
export 'input_limits.dart';
|
||||
|
||||
@@ -30,7 +30,7 @@ final baseInfoEdgeInsets = EdgeInsets.symmetric(
|
||||
);
|
||||
final listHeaderPadding = EdgeInsets.only(
|
||||
left: 16.mAp,
|
||||
right: 8.mAp,
|
||||
right: 16.mAp,
|
||||
top: 24.mAp,
|
||||
bottom: 8.mAp,
|
||||
);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class IconsExt {
|
||||
static const IconData target = IconData(0xe900, fontFamily: 'Icons');
|
||||
}
|
||||
@@ -83,7 +83,9 @@ abstract final class AppShape {
|
||||
static const RoundedSuperellipseBorder xxl = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.xxl,
|
||||
);
|
||||
static const StadiumBorder full = StadiumBorder();
|
||||
static const RoundedSuperellipseBorder full = RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.full,
|
||||
);
|
||||
static const CircleBorder circle = CircleBorder();
|
||||
static const AppInputBorder input = AppInputBorder();
|
||||
|
||||
@@ -273,5 +275,22 @@ extension AppShapeThemeExt on ThemeData {
|
||||
progressIndicatorTheme: progressIndicatorTheme.copyWith(
|
||||
borderRadius: AppRadius.full,
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: filledButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: textButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: outlinedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: elevatedButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: iconButtonTheme.style?.merge(_pillButton) ?? _pillButton,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _pillButton = ButtonStyle(shape: WidgetStatePropertyAll(AppShape.full));
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/context.dart';
|
||||
import 'package:fl_clash/common/system.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -136,12 +137,12 @@ extension LogLevelExt on LogLevel {
|
||||
enum MessageLevel { info, success, warning, error }
|
||||
|
||||
extension MessageLevelExt on MessageLevel {
|
||||
IconData? get icon {
|
||||
Glyph? get glyph {
|
||||
return switch (this) {
|
||||
MessageLevel.info => null,
|
||||
MessageLevel.success => Icons.check_circle_outline,
|
||||
MessageLevel.warning => Icons.warning_amber_outlined,
|
||||
MessageLevel.error => Icons.error_outline,
|
||||
MessageLevel.success => AppGlyphs.checkCircle,
|
||||
MessageLevel.warning => AppGlyphs.warning,
|
||||
MessageLevel.error => AppGlyphs.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,8 +348,7 @@ enum ProxiesIconStyle { none, standard, icon }
|
||||
|
||||
enum FontFamily {
|
||||
twEmoji('Twemoji'),
|
||||
jetBrainsMono('JetBrainsMono'),
|
||||
icon('Icons');
|
||||
jetBrainsMono('JetBrainsMono');
|
||||
|
||||
final String value;
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/icons/glyph.dart';
|
||||
import 'package:fl_clash/icons/glyph_icon.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _fillDuration = Duration(milliseconds: 350);
|
||||
const _popScale = 0.1;
|
||||
|
||||
class AnimatedGlyph extends StatefulWidget {
|
||||
const AnimatedGlyph({super.key, required this.glyph, required this.filled});
|
||||
|
||||
final Glyph glyph;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
State<AnimatedGlyph> createState() => _AnimatedGlyphState();
|
||||
}
|
||||
|
||||
class _AnimatedGlyphState extends State<AnimatedGlyph>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fillDuration,
|
||||
value: widget.filled ? 1 : 0,
|
||||
);
|
||||
late final CurvedAnimation _fill = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOutCubic,
|
||||
);
|
||||
late final AnimationController _pop = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fillDuration,
|
||||
);
|
||||
late final Listenable _animation = Listenable.merge([_fill, _pop]);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedGlyph oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filled == widget.filled) {
|
||||
return;
|
||||
}
|
||||
if (widget.filled) {
|
||||
_controller.forward();
|
||||
if (!_pop.isAnimating) {
|
||||
_pop.forward(from: 0);
|
||||
}
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fill.dispose();
|
||||
_controller.dispose();
|
||||
_pop.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, _) {
|
||||
final pop = _pop.isAnimating
|
||||
? math.sin(math.pi * _pop.value) * _popScale
|
||||
: 0.0;
|
||||
return Transform.scale(
|
||||
scale: 1 + pop,
|
||||
child: GlyphIcon(widget.glyph, fill: _fill.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:ui' show ClipOp;
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Windows 11 renders every caption glyph as a 10x10 Segoe Fluent icon.
|
||||
const captionGlyphSize = 10.0;
|
||||
|
||||
enum CaptionGlyph { minimize, maximize, restore, close }
|
||||
|
||||
class CaptionIcon extends StatelessWidget {
|
||||
const CaptionIcon(this.glyph, {super.key});
|
||||
|
||||
final CaptionGlyph glyph;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color =
|
||||
IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface;
|
||||
return CustomPaint(
|
||||
size: const Size.square(captionGlyphSize),
|
||||
painter: _CaptionGlyphPainter(glyph: glyph, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CaptionGlyphPainter extends CustomPainter {
|
||||
const _CaptionGlyphPainter({required this.glyph, required this.color});
|
||||
|
||||
final CaptionGlyph glyph;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
const corner = Radius.circular(1);
|
||||
// Every coordinate sits on a half pixel so a one pixel stroke covers a
|
||||
// single device pixel at 100% scaling.
|
||||
final box = (Offset.zero & size).deflate(0.5);
|
||||
switch (glyph) {
|
||||
case CaptionGlyph.minimize:
|
||||
final y = (size.height / 2).floorToDouble() + 0.5;
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
case CaptionGlyph.maximize:
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(box, corner),
|
||||
paint,
|
||||
);
|
||||
case CaptionGlyph.restore:
|
||||
const offset = 2.0;
|
||||
final front = Rect.fromLTRB(
|
||||
box.left,
|
||||
box.top + offset,
|
||||
box.right - offset,
|
||||
box.bottom,
|
||||
);
|
||||
final back = front.shift(const Offset(offset, -offset));
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(front, corner),
|
||||
paint,
|
||||
);
|
||||
canvas.save();
|
||||
canvas.clipRect(front.inflate(0.5), clipOp: ClipOp.difference);
|
||||
canvas.drawRSuperellipse(
|
||||
RSuperellipse.fromRectAndRadius(back, corner),
|
||||
paint,
|
||||
);
|
||||
canvas.restore();
|
||||
case CaptionGlyph.close:
|
||||
paint.strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(box.topLeft, box.bottomRight, paint);
|
||||
canvas.drawLine(box.topRight, box.bottomLeft, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_CaptionGlyphPainter oldDelegate) =>
|
||||
glyph != oldDelegate.glyph || color != oldDelegate.color;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:path_parsing/path_parsing.dart';
|
||||
|
||||
enum GlyphRole { body, detail, line }
|
||||
|
||||
class Glyph {
|
||||
const Glyph(this.shapes, {this.matchTextDirection = false});
|
||||
|
||||
final List<GlyphShape> shapes;
|
||||
final bool matchTextDirection;
|
||||
|
||||
static const size = 24.0;
|
||||
static const strokeWidth = 1.8;
|
||||
}
|
||||
|
||||
sealed class GlyphShape {
|
||||
const GlyphShape({this.role = GlyphRole.body, this.solid = false});
|
||||
|
||||
final GlyphRole role;
|
||||
final bool solid;
|
||||
|
||||
Path get path;
|
||||
}
|
||||
|
||||
class GlyphBox extends GlyphShape {
|
||||
const GlyphBox(
|
||||
this.left,
|
||||
this.top,
|
||||
this.right,
|
||||
this.bottom,
|
||||
this.radius, {
|
||||
super.role,
|
||||
});
|
||||
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Path get path => Path()
|
||||
..addRSuperellipse(
|
||||
RSuperellipse.fromLTRBR(
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
Radius.circular(radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class GlyphCircle extends GlyphShape {
|
||||
const GlyphCircle(this.x, this.y, this.radius, {super.role, super.solid});
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Path get path =>
|
||||
Path()..addOval(Rect.fromCircle(center: Offset(x, y), radius: radius));
|
||||
}
|
||||
|
||||
class GlyphOval extends GlyphShape {
|
||||
const GlyphOval(this.left, this.top, this.right, this.bottom, {super.role});
|
||||
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
@override
|
||||
Path get path => Path()..addOval(Rect.fromLTRB(left, top, right, bottom));
|
||||
}
|
||||
|
||||
class GlyphArc extends GlyphShape {
|
||||
const GlyphArc(
|
||||
this.x,
|
||||
this.y,
|
||||
this.radius,
|
||||
this.start,
|
||||
this.sweep, {
|
||||
super.role,
|
||||
});
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double radius;
|
||||
final double start;
|
||||
final double sweep;
|
||||
|
||||
@override
|
||||
Path get path => Path()
|
||||
..addArc(
|
||||
Rect.fromCircle(center: Offset(x, y), radius: radius),
|
||||
start,
|
||||
sweep,
|
||||
);
|
||||
}
|
||||
|
||||
/// Joins two circles of [radius] with a line whose round caps end on their
|
||||
/// strokes, so a cap never pokes into a hollow node.
|
||||
class GlyphLink extends GlyphShape {
|
||||
const GlyphLink(this.from, this.to, this.radius)
|
||||
: super(role: GlyphRole.line);
|
||||
|
||||
final Offset from;
|
||||
final Offset to;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Path get path {
|
||||
final delta = to - from;
|
||||
final inset = delta / delta.distance * (radius + Glyph.strokeWidth);
|
||||
final start = from + inset;
|
||||
final end = to - inset;
|
||||
return Path()
|
||||
..moveTo(start.dx, start.dy)
|
||||
..lineTo(end.dx, end.dy);
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG path data on the glyph grid, for curves the other shapes cannot draw.
|
||||
/// Like them it traces the center of the stroke, not an outline to fill.
|
||||
class GlyphPath extends GlyphShape {
|
||||
const GlyphPath(this.data, {super.role, super.solid});
|
||||
|
||||
final String data;
|
||||
|
||||
static final _parsed = Expando<Path>();
|
||||
|
||||
@override
|
||||
Path get path => _parsed[this] ??= _parse(data);
|
||||
|
||||
static Path _parse(String data) {
|
||||
final writer = _PathWriter();
|
||||
writeSvgPathDataToPath(data, writer);
|
||||
return writer.path;
|
||||
}
|
||||
}
|
||||
|
||||
class _PathWriter implements PathProxy {
|
||||
final path = Path();
|
||||
|
||||
@override
|
||||
void moveTo(double x, double y) => path.moveTo(x, y);
|
||||
|
||||
@override
|
||||
void lineTo(double x, double y) => path.lineTo(x, y);
|
||||
|
||||
@override
|
||||
void cubicTo(
|
||||
double x1,
|
||||
double y1,
|
||||
double x2,
|
||||
double y2,
|
||||
double x3,
|
||||
double y3,
|
||||
) => path.cubicTo(x1, y1, x2, y2, x3, y3);
|
||||
|
||||
@override
|
||||
void close() => path.close();
|
||||
}
|
||||
|
||||
class GlyphVertex {
|
||||
const GlyphVertex(this.x, this.y, [this.radius = 0]);
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double radius;
|
||||
|
||||
Offset get offset => Offset(x, y);
|
||||
}
|
||||
|
||||
class GlyphPolyline extends GlyphShape {
|
||||
const GlyphPolyline(this.vertices, {this.closed = false, super.role});
|
||||
|
||||
final List<GlyphVertex> vertices;
|
||||
final bool closed;
|
||||
|
||||
(Offset, Offset) _corner(int index) {
|
||||
final count = vertices.length;
|
||||
final vertex = vertices[index].offset;
|
||||
final previous = vertices[(index - 1 + count) % count].offset;
|
||||
final next = vertices[(index + 1) % count].offset;
|
||||
final toPrevious = previous - vertex;
|
||||
final toNext = next - vertex;
|
||||
final radius = vertices[index].radius;
|
||||
return (
|
||||
vertex +
|
||||
toPrevious /
|
||||
toPrevious.distance *
|
||||
math.min(radius, toPrevious.distance / 2),
|
||||
vertex + toNext / toNext.distance * math.min(radius, toNext.distance / 2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Path get path {
|
||||
final path = Path();
|
||||
final count = vertices.length;
|
||||
final first = closed ? _corner(0).$2 : vertices.first.offset;
|
||||
path.moveTo(first.dx, first.dy);
|
||||
final last = closed ? count : count - 1;
|
||||
for (var index = 1; index <= last; index++) {
|
||||
final vertex = vertices[index % count];
|
||||
if (!closed && index == count - 1) {
|
||||
path.lineTo(vertex.x, vertex.y);
|
||||
break;
|
||||
}
|
||||
final (enter, exit) = _corner(index % count);
|
||||
path
|
||||
..lineTo(enter.dx, enter.dy)
|
||||
..quadraticBezierTo(vertex.x, vertex.y, exit.dx, exit.dy);
|
||||
}
|
||||
if (closed) {
|
||||
path.close();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:fl_clash/icons/glyph.dart';
|
||||
import 'package:fl_clash/icons/glyph_painter.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class GlyphIcon extends StatelessWidget {
|
||||
const GlyphIcon(this.glyph, {super.key, this.fill, this.size, this.color});
|
||||
|
||||
final Glyph glyph;
|
||||
final double? fill;
|
||||
final double? size;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final color = this.color ?? iconTheme.color!;
|
||||
final opacity = iconTheme.opacity ?? 1;
|
||||
final side = size ?? iconTheme.size ?? Glyph.size;
|
||||
final icon = SizedBox.square(
|
||||
dimension: side,
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: Size.square(side),
|
||||
painter: GlyphPainter(
|
||||
glyph: glyph,
|
||||
fill: fill ?? iconTheme.fill ?? 0,
|
||||
color: color.withValues(alpha: color.a * opacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (glyph.matchTextDirection &&
|
||||
Directionality.of(context) == TextDirection.rtl) {
|
||||
return Transform.flip(flipX: true, child: icon);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:fl_clash/icons/glyph.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _grid = Rect.fromLTWH(0, 0, Glyph.size, Glyph.size);
|
||||
const _fillStart = Glyph.strokeWidth / 2;
|
||||
|
||||
// Details thin away before this point and return as cutouts after it, so a
|
||||
// detail never shows as a stroke and a cutout at once.
|
||||
const _detailSwap = 0.5;
|
||||
|
||||
// Lines have nothing to fill, so they gain weight instead and hold their own
|
||||
// beside filled bodies.
|
||||
const _filledLineWidth = 2.2;
|
||||
|
||||
class GlyphPainter extends CustomPainter {
|
||||
const GlyphPainter({
|
||||
required this.glyph,
|
||||
required this.fill,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final Glyph glyph;
|
||||
final double fill;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final opaque = color.withValues(alpha: 1);
|
||||
final stroke = _stroke(opaque, Glyph.strokeWidth);
|
||||
final line = _stroke(
|
||||
opaque,
|
||||
lerpDouble(Glyph.strokeWidth, _filledLineWidth, fill.clamp(0, 1))!,
|
||||
);
|
||||
final area = Paint()..color = opaque;
|
||||
Paint paintFor(GlyphShape shape) => shape.solid
|
||||
? area
|
||||
: shape.role == GlyphRole.line
|
||||
? line
|
||||
: stroke;
|
||||
|
||||
final cut = fill > _detailSwap;
|
||||
final detailScale = cut
|
||||
? (fill - _detailSwap) / (1 - _detailSwap)
|
||||
: 1 - fill / _detailSwap;
|
||||
canvas.save();
|
||||
canvas.scale(size.shortestSide / Glyph.size);
|
||||
// Opaque strokes under one alpha keep a translucent color even on overlaps,
|
||||
// and cutouts clear only the glyph's own layer.
|
||||
if (color.a < 1 || (cut && detailScale > 0)) {
|
||||
canvas.saveLayer(
|
||||
_grid,
|
||||
Paint()..color = Color.fromRGBO(0, 0, 0, color.a),
|
||||
);
|
||||
} else {
|
||||
canvas.save();
|
||||
}
|
||||
for (final shape in glyph.shapes) {
|
||||
if (shape.role == GlyphRole.body) {
|
||||
_paintFill(canvas, shape.path, opaque);
|
||||
}
|
||||
}
|
||||
if (detailScale > 0) {
|
||||
for (final shape in glyph.shapes) {
|
||||
if (shape.role == GlyphRole.detail) {
|
||||
_paintDetail(canvas, shape, opaque, detailScale, cut: cut);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final shape in glyph.shapes) {
|
||||
if (shape.role != GlyphRole.detail) {
|
||||
canvas.drawPath(shape.path, paintFor(shape));
|
||||
}
|
||||
}
|
||||
canvas.restore();
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintFill(Canvas canvas, Path body, Color color) {
|
||||
if (fill <= 0) {
|
||||
return;
|
||||
}
|
||||
if (fill >= 1) {
|
||||
canvas.drawPath(body, Paint()..color = color);
|
||||
return;
|
||||
}
|
||||
final depth = body.getBounds().shortestSide / 2;
|
||||
final reach = _fillStart + (depth - _fillStart) * fill;
|
||||
canvas.save();
|
||||
canvas.clipPath(body);
|
||||
canvas.drawPath(body, _stroke(color, reach * 2));
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintDetail(
|
||||
Canvas canvas,
|
||||
GlyphShape detail,
|
||||
Color color,
|
||||
double scale, {
|
||||
required bool cut,
|
||||
}) {
|
||||
final blendMode = cut ? BlendMode.clear : BlendMode.srcOver;
|
||||
if (!detail.solid) {
|
||||
canvas.drawPath(
|
||||
detail.path,
|
||||
_stroke(color, Glyph.strokeWidth * scale)..blendMode = blendMode,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final center = detail.path.getBounds().center;
|
||||
canvas.save();
|
||||
canvas.translate(center.dx, center.dy);
|
||||
canvas.scale(scale);
|
||||
canvas.translate(-center.dx, -center.dy);
|
||||
canvas.drawPath(
|
||||
detail.path,
|
||||
Paint()
|
||||
..color = color
|
||||
..blendMode = blendMode,
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(GlyphPainter oldDelegate) =>
|
||||
glyph != oldDelegate.glyph ||
|
||||
fill != oldDelegate.fill ||
|
||||
color != oldDelegate.color;
|
||||
}
|
||||
|
||||
Paint _stroke(Color color, double width) => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = width
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
@@ -0,0 +1,6 @@
|
||||
export 'animated_glyph.dart';
|
||||
export 'app_glyphs.dart';
|
||||
export 'caption_icon.dart';
|
||||
export 'glyph.dart';
|
||||
export 'glyph_icon.dart';
|
||||
export 'glyph_painter.dart';
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:fl_clash/common/color.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/navigation_dock.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -28,8 +30,8 @@ class InitErrorScreen extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.report_problem,
|
||||
GlyphIcon(
|
||||
AppGlyphs.warning,
|
||||
color: colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
@@ -89,12 +91,14 @@ class InitErrorScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _copyToClipboard(context),
|
||||
label: const Text('Copy Details'),
|
||||
icon: const Icon(Icons.copy),
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
floatingActionButton: ElasticButton(
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _copyToClipboard(context),
|
||||
label: const Text('Copy Details'),
|
||||
icon: const GlyphIcon(AppGlyphs.copy, fill: 1),
|
||||
backgroundColor: colorScheme.error,
|
||||
foregroundColor: colorScheme.onError,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,32 +32,49 @@ ColorScheme genColorScheme(
|
||||
Brightness brightness, {
|
||||
Color? color,
|
||||
bool ignoreConfig = false,
|
||||
bool? pureBlack,
|
||||
}) {
|
||||
final themeSetting = ref.watch(
|
||||
themeSettingProvider.select(
|
||||
(state) => (
|
||||
primaryColor: state.primaryColor,
|
||||
schemeVariant: state.schemeVariant,
|
||||
pureBlack: state.pureBlack,
|
||||
),
|
||||
),
|
||||
);
|
||||
final dynamicColor = ref.watch(dynamicColorProvider);
|
||||
final Color seedColor;
|
||||
if (color == null &&
|
||||
(ignoreConfig == true || themeSetting.primaryColor == null)) {
|
||||
final seed = switch (brightness) {
|
||||
Brightness.light => dynamicColor.lightSeed,
|
||||
Brightness.dark => dynamicColor.darkSeed,
|
||||
};
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: seed ?? dynamicColor.accentColor,
|
||||
brightness: brightness,
|
||||
dynamicSchemeVariant: themeSetting.schemeVariant,
|
||||
);
|
||||
seedColor = seed ?? dynamicColor.accentColor;
|
||||
} else {
|
||||
seedColor = color ?? Color(themeSetting.primaryColor!);
|
||||
}
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: color ?? Color(themeSetting.primaryColor!),
|
||||
seedColor: seedColor,
|
||||
brightness: brightness,
|
||||
dynamicSchemeVariant: themeSetting.schemeVariant,
|
||||
).toPureBlack(pureBlack ?? themeSetting.pureBlack);
|
||||
}
|
||||
|
||||
typedef WindowBlurRequest = ({bool enabled, Brightness brightness, Color tint});
|
||||
|
||||
@riverpod
|
||||
WindowBlurRequest windowBlurRequest(Ref ref) {
|
||||
final enabled =
|
||||
feature.sidebarBlur &&
|
||||
ref.watch(themeSettingProvider.select((state) => state.sidebarBlur));
|
||||
final brightness = ref.watch(currentBrightnessProvider);
|
||||
final colorScheme = ref.watch(genColorSchemeProvider(brightness));
|
||||
return (
|
||||
enabled: enabled,
|
||||
brightness: brightness,
|
||||
tint: colorScheme.surfaceContainer,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/views/theme_preview.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
|
||||
class ThemeModeItem {
|
||||
final ThemeMode themeMode;
|
||||
final IconData iconData;
|
||||
final String label;
|
||||
|
||||
const ThemeModeItem({
|
||||
required this.themeMode,
|
||||
required this.iconData,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
class FontFamilyItem {
|
||||
final FontFamily fontFamily;
|
||||
final String label;
|
||||
@@ -36,16 +25,24 @@ class ThemeView extends StatelessWidget {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
return BaseScaffold(
|
||||
title: appLocalizations.theme,
|
||||
body: const CustomScrollView(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_ThemeModeItem(),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
_PrimaryColorItem(),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
_PrueBlackItem(),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
_TextScaleFactorItem(),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: context.appBarInset)),
|
||||
const SliverToBoxAdapter(child: ThemeLivePreview()),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _ThemeModeItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _PureBlackItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _PrimaryColorItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _NavigationBarItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _TabAnimationItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
const _SidebarBlurItem(),
|
||||
const _TextScaleFactorItem(),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -56,12 +53,14 @@ class ItemCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final Info info;
|
||||
final List<Widget> actions;
|
||||
final double? space;
|
||||
|
||||
const ItemCard({
|
||||
super.key,
|
||||
required this.info,
|
||||
required this.child,
|
||||
this.actions = const [],
|
||||
this.space,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -69,7 +68,7 @@ class ItemCard extends StatelessWidget {
|
||||
return Wrap(
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
InfoHeader(info: info, actions: actions),
|
||||
InfoHeader(info: info, actions: actions, space: space),
|
||||
child,
|
||||
],
|
||||
);
|
||||
@@ -85,71 +84,57 @@ class _ThemeModeItem extends ConsumerWidget {
|
||||
final themeMode = ref.watch(
|
||||
themeSettingProvider.select((state) => state.themeMode),
|
||||
);
|
||||
final List<ThemeModeItem> themeModeItems = [
|
||||
ThemeModeItem(
|
||||
iconData: Icons.auto_mode,
|
||||
label: appLocalizations.auto,
|
||||
themeMode: ThemeMode.system,
|
||||
),
|
||||
ThemeModeItem(
|
||||
iconData: Icons.light_mode,
|
||||
label: appLocalizations.light,
|
||||
themeMode: ThemeMode.light,
|
||||
),
|
||||
ThemeModeItem(
|
||||
iconData: Icons.dark_mode,
|
||||
label: appLocalizations.dark,
|
||||
themeMode: ThemeMode.dark,
|
||||
),
|
||||
];
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final light = MiniScreen(
|
||||
colorScheme: ref.watch(genColorSchemeProvider(Brightness.light)),
|
||||
floatingBar: floatingBar,
|
||||
);
|
||||
final dark = MiniScreen(
|
||||
colorScheme: ref.watch(genColorSchemeProvider(Brightness.dark)),
|
||||
floatingBar: floatingBar,
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: ItemCard(
|
||||
info: Info(
|
||||
label: appLocalizations.themeMode,
|
||||
iconData: Icons.brightness_high,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 56,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: themeModeItems.length,
|
||||
itemBuilder: (_, index) {
|
||||
final themeModeItem = themeModeItems[index];
|
||||
return CommonCard(
|
||||
isSelected: themeModeItem.themeMode == themeMode,
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update(
|
||||
(state) =>
|
||||
state.copyWith(themeMode: themeModeItem.themeMode),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(child: Icon(themeModeItem.iconData)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(themeModeItem.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) {
|
||||
return const SizedBox(width: 16);
|
||||
},
|
||||
child: PreviewChoiceGroup<ThemeMode>(
|
||||
info: Info(label: appLocalizations.themeMode, glyph: AppGlyphs.sun),
|
||||
value: themeMode,
|
||||
choices: [
|
||||
PreviewChoice(
|
||||
value: ThemeMode.system,
|
||||
label: appLocalizations.auto,
|
||||
pictogram: MiniScreenThumb(
|
||||
screen: MiniSplitScreen(light: light, dark: dark),
|
||||
),
|
||||
),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: ThemeMode.light,
|
||||
label: appLocalizations.light,
|
||||
pictogram: MiniScreenThumb(screen: light),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: ThemeMode.dark,
|
||||
label: appLocalizations.dark,
|
||||
pictogram: MiniScreenThumb(screen: dark),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(themeMode: value));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const double _headerButtonHeight = 32;
|
||||
|
||||
final _headerButtonStyle = FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, _headerButtonHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
);
|
||||
|
||||
class _PrimaryColorItem extends ConsumerStatefulWidget {
|
||||
const _PrimaryColorItem();
|
||||
|
||||
@@ -281,47 +266,51 @@ class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: CommonPopScope(
|
||||
onPop: (context) {
|
||||
if (_removablePrimaryColor != null) {
|
||||
setState(() {
|
||||
_removablePrimaryColor = null;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onPop: _removablePrimaryColor == null
|
||||
? null
|
||||
: (_) {
|
||||
_clearRemovable();
|
||||
return false;
|
||||
},
|
||||
child: ItemCard(
|
||||
info: Info(
|
||||
label: appLocalizations.themeColor,
|
||||
iconData: Icons.palette,
|
||||
glyph: AppGlyphs.palette,
|
||||
),
|
||||
actions: genActions([
|
||||
space: 8,
|
||||
actions: [
|
||||
if (_removablePrimaryColor == null)
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
ElasticButton(
|
||||
child: FilledButton.tonal(
|
||||
style: _headerButtonStyle,
|
||||
onPressed: _handleChangeSchemeVariant,
|
||||
child: Text(schemeVariant.label),
|
||||
),
|
||||
onPressed: _handleChangeSchemeVariant,
|
||||
child: Text(schemeVariant.label),
|
||||
),
|
||||
if (_removablePrimaryColor != null)
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
ElasticButton(
|
||||
child: FilledButton(
|
||||
style: _headerButtonStyle,
|
||||
onPressed: _clearRemovable,
|
||||
child: Text(appLocalizations.cancel),
|
||||
),
|
||||
onPressed: _clearRemovable,
|
||||
child: Text(appLocalizations.cancel),
|
||||
),
|
||||
if (_removablePrimaryColor == null && !isEquals)
|
||||
IconButton.filledTonal(
|
||||
tooltip: context.appLocalizations.reset,
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: _handleReset,
|
||||
icon: const Icon(Icons.replay),
|
||||
ElasticButton(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: context.appLocalizations.reset,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _headerButtonHeight,
|
||||
height: _headerButtonHeight,
|
||||
),
|
||||
visualDensity: VisualDensity.standard,
|
||||
onPressed: _handleReset,
|
||||
icon: const GlyphIcon(AppGlyphs.reset, fill: 1),
|
||||
),
|
||||
),
|
||||
], space: 8),
|
||||
],
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _PrimaryColorGrid(
|
||||
@@ -370,6 +359,8 @@ class _PrimaryColorGrid extends StatelessWidget {
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
static const double _size = 48;
|
||||
|
||||
final List<int?> colors;
|
||||
final int? selectedColor;
|
||||
final int? removableColor;
|
||||
@@ -380,29 +371,29 @@ class _PrimaryColorGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final columns = max((constraints.maxWidth / 96).ceil(), 3);
|
||||
final itemWidth = (constraints.maxWidth - (columns - 1) * 16) / columns;
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (final color in colors)
|
||||
_PrimaryColorTile(
|
||||
color: color,
|
||||
width: itemWidth,
|
||||
isSelected: color == selectedColor,
|
||||
isRemovable: removableColor != null && removableColor == color,
|
||||
onSelect: () => onSelect(color),
|
||||
onRequestRemove: () => onRequestRemove(color),
|
||||
onDelete: onDelete,
|
||||
),
|
||||
if (removableColor == null)
|
||||
_AddPrimaryColorTile(width: itemWidth, onPressed: onAdd),
|
||||
],
|
||||
);
|
||||
},
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final color in colors)
|
||||
_PrimaryColorTile(
|
||||
color: color,
|
||||
isSelected: color == selectedColor,
|
||||
isRemovable: removableColor != null && removableColor == color,
|
||||
onSelect: () => onSelect(color),
|
||||
onRequestRemove: () => onRequestRemove(color),
|
||||
onDelete: onDelete,
|
||||
),
|
||||
if (removableColor == null)
|
||||
SizedBox.square(
|
||||
dimension: _size,
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: context.appLocalizations.add,
|
||||
onPressed: onAdd,
|
||||
icon: const GlyphIcon(AppGlyphs.add, fill: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -410,7 +401,6 @@ class _PrimaryColorGrid extends StatelessWidget {
|
||||
class _PrimaryColorTile extends StatelessWidget {
|
||||
const _PrimaryColorTile({
|
||||
required this.color,
|
||||
required this.width,
|
||||
required this.isSelected,
|
||||
required this.isRemovable,
|
||||
required this.onSelect,
|
||||
@@ -419,7 +409,6 @@ class _PrimaryColorTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
final int? color;
|
||||
final double width;
|
||||
final bool isSelected;
|
||||
final bool isRemovable;
|
||||
final VoidCallback onSelect;
|
||||
@@ -428,82 +417,85 @@ class _PrimaryColorTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.none,
|
||||
width: width,
|
||||
height: width,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
EffectGestureDetector(
|
||||
onLongPress: onRequestRemove,
|
||||
child: ColorSchemeBox(
|
||||
isSelected: isSelected,
|
||||
primaryColor: color != null ? Color(color!) : null,
|
||||
onPressed: onSelect,
|
||||
),
|
||||
final colorScheme = context.colorScheme;
|
||||
return Stack(
|
||||
children: [
|
||||
EffectGestureDetector(
|
||||
onLongPress: onRequestRemove,
|
||||
child: ColorSchemeBox(
|
||||
isSelected: isSelected,
|
||||
primaryColor: color != null ? Color(color!) : null,
|
||||
onPressed: onSelect,
|
||||
size: _PrimaryColorGrid._size,
|
||||
),
|
||||
if (isRemovable)
|
||||
Container(
|
||||
color: Colors.white.opacity0,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: context.appLocalizations.delete,
|
||||
onPressed: onDelete,
|
||||
padding: const EdgeInsets.all(12),
|
||||
iconSize: 30,
|
||||
icon: Icon(color: context.colorScheme.primary, Icons.delete),
|
||||
),
|
||||
if (isRemovable)
|
||||
Positioned.fill(
|
||||
child: Tooltip(
|
||||
message: context.appLocalizations.delete,
|
||||
child: Material(
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.9),
|
||||
shape: AppShape.circle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: AppShape.circle,
|
||||
onTap: onDelete,
|
||||
child: Center(
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.delete,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddPrimaryColorTile extends StatelessWidget {
|
||||
const _AddPrimaryColorTile({required this.width, required this.onPressed});
|
||||
final _floatingBarProvider = appSettingProvider.select(
|
||||
(state) => state.floatingNavigationBar,
|
||||
);
|
||||
|
||||
final double width;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: width,
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: context.appLocalizations.add,
|
||||
onPressed: onPressed,
|
||||
iconSize: 32,
|
||||
icon: Icon(color: context.colorScheme.primary, Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrueBlackItem extends ConsumerWidget {
|
||||
const _PrueBlackItem();
|
||||
class _PureBlackItem extends ConsumerWidget {
|
||||
const _PureBlackItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final prueBlack = ref.watch(
|
||||
final pureBlack = ref.watch(
|
||||
themeSettingProvider.select((state) => state.pureBlack),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: ListItem.toggle(
|
||||
leading: const Icon(Icons.contrast),
|
||||
horizontalTitleGap: 12,
|
||||
title: Text(
|
||||
appLocalizations.pureBlackMode,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
Widget preview(bool pureBlack) => MiniScreenThumb(
|
||||
screen: MiniScreen(
|
||||
colorScheme: ref.watch(
|
||||
genColorSchemeProvider(Brightness.dark, pureBlack: pureBlack),
|
||||
),
|
||||
value: prueBlack,
|
||||
floatingBar: floatingBar,
|
||||
),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<bool>(
|
||||
info: Info(
|
||||
label: appLocalizations.pureBlackMode,
|
||||
glyph: AppGlyphs.pureBlack,
|
||||
),
|
||||
value: pureBlack,
|
||||
choices: [
|
||||
PreviewChoice(
|
||||
value: false,
|
||||
label: appLocalizations.standard,
|
||||
pictogram: preview(false),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: true,
|
||||
label: appLocalizations.pureBlack,
|
||||
pictogram: preview(true),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
@@ -514,77 +506,412 @@ class _PrueBlackItem extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TextScaleFactorItem extends ConsumerWidget {
|
||||
const _TextScaleFactorItem();
|
||||
class _NavigationBarItem extends ConsumerWidget {
|
||||
const _NavigationBarItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final colorScheme = context.colorScheme;
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<bool>(
|
||||
info: Info(
|
||||
label: appLocalizations.navigationBarStyle,
|
||||
glyph: AppGlyphs.layers,
|
||||
),
|
||||
value: floatingBar,
|
||||
choices: [
|
||||
PreviewChoice(
|
||||
value: true,
|
||||
label: appLocalizations.floating,
|
||||
pictogram: MiniScreenThumb(
|
||||
screen: MiniScreen(colorScheme: colorScheme, floatingBar: true),
|
||||
),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: false,
|
||||
label: appLocalizations.docked,
|
||||
pictogram: MiniScreenThumb(
|
||||
screen: MiniScreen(colorScheme: colorScheme, floatingBar: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(floatingNavigationBar: value));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabAnimationItem extends ConsumerWidget {
|
||||
const _TabAnimationItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final tabAnimation = ref.watch(
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
);
|
||||
final floatingBar = ref.watch(_floatingBarProvider);
|
||||
final colorScheme = context.colorScheme;
|
||||
Widget switching(TabAnimation tabAnimation) => MiniScreenThumb(
|
||||
screen: MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
selected: 1,
|
||||
previous: 0,
|
||||
progress: 0.55,
|
||||
tabAnimation: tabAnimation,
|
||||
),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: PreviewChoiceGroup<TabAnimation>(
|
||||
info: Info(
|
||||
label: appLocalizations.tabAnimation,
|
||||
glyph: AppGlyphs.motion,
|
||||
),
|
||||
value: tabAnimation,
|
||||
choices: [
|
||||
PreviewChoice(
|
||||
value: TabAnimation.slide,
|
||||
label: appLocalizations.slide,
|
||||
pictogram: switching(TabAnimation.slide),
|
||||
),
|
||||
PreviewChoice(
|
||||
value: TabAnimation.fade,
|
||||
label: appLocalizations.fade,
|
||||
pictogram: switching(TabAnimation.fade),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(tabAnimation: value));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarBlurItem extends ConsumerWidget {
|
||||
const _SidebarBlurItem();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!feature.sidebarBlur || (!system.isMacOS && !system.isWindows)) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final sidebarBlur = ref.watch(
|
||||
themeSettingProvider.select((state) => state.sidebarBlur),
|
||||
);
|
||||
return SliverToBoxAdapter(
|
||||
child: ListItem.toggle(
|
||||
leading: const GlyphIcon(AppGlyphs.blur),
|
||||
horizontalTitleGap: 12,
|
||||
title: Text(
|
||||
appLocalizations.sidebarBlur,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
subtitle: Text(appLocalizations.sidebarBlurDesc),
|
||||
value: sidebarBlur,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(sidebarBlur: value));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextScaleFactorItem extends ConsumerStatefulWidget {
|
||||
const _TextScaleFactorItem();
|
||||
|
||||
static const _step = 0.05;
|
||||
|
||||
@override
|
||||
ConsumerState<_TextScaleFactorItem> createState() =>
|
||||
_TextScaleFactorItemState();
|
||||
}
|
||||
|
||||
class _TextScaleFactorItemState extends ConsumerState<_TextScaleFactorItem> {
|
||||
double? _draft;
|
||||
|
||||
void _update(TextScale Function(TextScale textScale) update) {
|
||||
setState(() => _draft = null);
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(textScale: update(state.textScale)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appLocalizations = context.appLocalizations;
|
||||
final colorScheme = context.colorScheme;
|
||||
final textScale = ref.watch(
|
||||
themeSettingProvider.select((state) => state.textScale),
|
||||
);
|
||||
final String process = '${(textScale.scale * 100).round()}%';
|
||||
final systemScale = defaultTextScaleFactor
|
||||
.clamp(minTextScale, maxTextScale)
|
||||
.toDouble();
|
||||
final scale = _draft ?? (textScale.enable ? textScale.scale : systemScale);
|
||||
final percent = '${(scale * 100).round()}%';
|
||||
final divisions =
|
||||
((maxTextScale - minTextScale) / _TextScaleFactorItem._step).round();
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ListItem.toggle(
|
||||
leading: const Icon(Icons.text_fields),
|
||||
horizontalTitleGap: 12,
|
||||
title: Text(
|
||||
appLocalizations.textScale,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: textScale.enable,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith.textScale(enable: value));
|
||||
},
|
||||
InfoHeader(
|
||||
info: Info(
|
||||
label: appLocalizations.textScale,
|
||||
glyph: AppGlyphs.textSize,
|
||||
),
|
||||
actions: [
|
||||
if (textScale.enable && textScale.scale != 1)
|
||||
ElasticButton(
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: appLocalizations.reset,
|
||||
iconSize: 18,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _headerButtonHeight,
|
||||
height: _headerButtonHeight,
|
||||
),
|
||||
visualDensity: VisualDensity.standard,
|
||||
onPressed: () =>
|
||||
_update((state) => state.copyWith(scale: 1)),
|
||||
icon: const GlyphIcon(AppGlyphs.reset, fill: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
spacing: 32,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_SegmentedToggle(
|
||||
value: textScale.enable,
|
||||
offLabel: appLocalizations.followSystem,
|
||||
onLabel: appLocalizations.custom,
|
||||
onChanged: (value) =>
|
||||
_update((state) => state.copyWith(enable: value)),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: AppShape.lg,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(scale),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
'Aa',
|
||||
style: context.textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
appLocalizations.textScalePreview,
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Text(
|
||||
percent,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
MediaQuery.withNoTextScaling(
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
'A',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderDefaultsM3(context),
|
||||
child: Slider(
|
||||
padding: EdgeInsets.zero,
|
||||
min: minTextScale,
|
||||
max: maxTextScale,
|
||||
divisions: divisions,
|
||||
value: scale,
|
||||
label: percent,
|
||||
onChanged: textScale.enable
|
||||
? (value) =>
|
||||
setState(() => _draft = value)
|
||||
: null,
|
||||
onChangeEnd: (value) => _update(
|
||||
(state) => state.copyWith(scale: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
'A',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SegmentedToggle extends StatelessWidget {
|
||||
const _SegmentedToggle({
|
||||
required this.value,
|
||||
required this.offLabel,
|
||||
required this.onLabel,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
static const double _height = 44;
|
||||
static const double _inset = 4;
|
||||
|
||||
final bool value;
|
||||
final String offLabel;
|
||||
final String onLabel;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = context.colorScheme;
|
||||
return Container(
|
||||
height: _height,
|
||||
padding: const EdgeInsets.all(_inset),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: value
|
||||
? AlignmentDirectional.centerEnd
|
||||
: AlignmentDirectional.centerStart,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 0.5,
|
||||
heightFactor: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (final (option, label) in [
|
||||
(false, offLabel),
|
||||
(true, onLabel),
|
||||
])
|
||||
Expanded(
|
||||
child: DisabledMask(
|
||||
status: !textScale.enable,
|
||||
child: ActivateBox(
|
||||
active: textScale.enable,
|
||||
child: SliderTheme(
|
||||
data: SliderDefaultsM3(context),
|
||||
child: Slider(
|
||||
padding: EdgeInsets.zero,
|
||||
min: minTextScale,
|
||||
max: maxTextScale,
|
||||
value: textScale.scale,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update(
|
||||
(state) =>
|
||||
state.copyWith.textScale(scale: value),
|
||||
);
|
||||
},
|
||||
child: Semantics(
|
||||
selected: option == value,
|
||||
button: true,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
shape: AppShape.full,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: AppShape.full,
|
||||
onTap: option == value ? null : () => onChanged(option),
|
||||
child: Center(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
color: option == value
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: option == value
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Text(process, style: context.textTheme.titleMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/providers/config.dart';
|
||||
import 'package:fl_clash/providers/state.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class PreviewChoice<T> {
|
||||
const PreviewChoice({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.pictogram,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final String label;
|
||||
final Widget pictogram;
|
||||
}
|
||||
|
||||
class PreviewChoiceGroup<T> extends StatelessWidget {
|
||||
const PreviewChoiceGroup({
|
||||
super.key,
|
||||
required this.info,
|
||||
required this.choices,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Info info;
|
||||
final List<PreviewChoice<T>> choices;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoHeader(info: info),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final choice in choices)
|
||||
_ChoiceButton(
|
||||
label: choice.label,
|
||||
isSelected: choice.value == value,
|
||||
onPressed: () => onChanged(choice.value),
|
||||
pictogram: choice.pictogram,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChoiceButton extends StatelessWidget {
|
||||
const _ChoiceButton({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onPressed,
|
||||
required this.pictogram,
|
||||
});
|
||||
|
||||
static const double _height = 48;
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
final Widget pictogram;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
selected: isSelected,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: _height),
|
||||
child: CommonCard(
|
||||
isSelected: isSelected,
|
||||
onPressed: onPressed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ExcludeSemantics(child: pictogram),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The foot of a [MiniScreen], where the options differ, drawn at the size of
|
||||
/// an icon.
|
||||
class MiniScreenThumb extends StatelessWidget {
|
||||
const MiniScreenThumb({super.key, required this.screen});
|
||||
|
||||
static const Size _screenSize = Size(96, 128);
|
||||
static const double _shownHeight = 66;
|
||||
static const double _height = 30;
|
||||
|
||||
final Widget screen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = AppShape.sm.copyWith(
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
);
|
||||
return SizedBox(
|
||||
height: _height,
|
||||
width: _height * _screenSize.width / _shownHeight,
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: ShapeDecoration(shape: shape),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.sm,
|
||||
child: FittedBox(
|
||||
child: SizedBox(
|
||||
width: _screenSize.width,
|
||||
height: _shownHeight,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.bottomCenter,
|
||||
minHeight: _screenSize.height,
|
||||
maxHeight: _screenSize.height,
|
||||
child: screen,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSchemeTween extends Tween<ColorScheme> {
|
||||
_ColorSchemeTween({super.end});
|
||||
|
||||
@override
|
||||
ColorScheme lerp(double t) => ColorScheme.lerp(begin!, end!, t);
|
||||
}
|
||||
|
||||
/// The home page as the theme settings will draw it; its destinations can be
|
||||
/// tapped to try the tab animation.
|
||||
class ThemeLivePreview extends ConsumerStatefulWidget {
|
||||
const ThemeLivePreview({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ThemeLivePreview> createState() => _ThemeLivePreviewState();
|
||||
}
|
||||
|
||||
class _ThemeLivePreviewState extends ConsumerState<ThemeLivePreview>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const double _phoneWidth = 168;
|
||||
static const _duration = Duration(milliseconds: 300);
|
||||
|
||||
late final AnimationController _slide = AnimationController(
|
||||
vsync: this,
|
||||
duration: kTabScrollDuration,
|
||||
);
|
||||
late final CurvedAnimation _slideCurve = CurvedAnimation(
|
||||
parent: _slide,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
int _selected = 0;
|
||||
int? _previous;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual(
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
(_, _) => _select((_selected + 1) % MiniScreen.destinationCount),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _select(int index) async {
|
||||
if (index == _selected) {
|
||||
return;
|
||||
}
|
||||
final tabAnimation = ref.read(appSettingProvider).tabAnimation;
|
||||
final fade = tabAnimation == TabAnimation.fade;
|
||||
_slide.duration = fade ? fadeTabDuration : kTabScrollDuration;
|
||||
_slideCurve.curve = fade ? fadeTabCurve : Curves.easeOut;
|
||||
setState(() {
|
||||
_previous = _selected;
|
||||
_selected = index;
|
||||
});
|
||||
await _slide.forward(from: 0).orCancel.catchError((_) {});
|
||||
if (mounted && _selected == index) {
|
||||
setState(() => _previous = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideCurve.dispose();
|
||||
_slide.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeMode = ref.watch(
|
||||
themeSettingProvider.select((state) => state.themeMode),
|
||||
);
|
||||
final brightness = switch (themeMode) {
|
||||
ThemeMode.light => Brightness.light,
|
||||
ThemeMode.dark => Brightness.dark,
|
||||
ThemeMode.system => MediaQuery.platformBrightnessOf(context),
|
||||
};
|
||||
final colorScheme = ref.watch(genColorSchemeProvider(brightness));
|
||||
final floatingBar = ref.watch(
|
||||
appSettingProvider.select((state) => state.floatingNavigationBar),
|
||||
);
|
||||
final tabAnimation = ref.watch(
|
||||
appSettingProvider.select((state) => state.tabAnimation),
|
||||
);
|
||||
final corner = AppCorner.fit(_phoneWidth);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
alignment: Alignment.center,
|
||||
decoration: ShapeDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
shape: AppShape.xxl,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: _phoneWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 9 / 17,
|
||||
child: DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.all(corner).copyWith(
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(corner),
|
||||
child: TweenAnimationBuilder<ColorScheme>(
|
||||
tween: _ColorSchemeTween(end: colorScheme),
|
||||
duration: _duration,
|
||||
builder: (_, colorScheme, _) => AnimatedSwitcher(
|
||||
duration: _duration,
|
||||
child: AnimatedBuilder(
|
||||
key: ValueKey(floatingBar),
|
||||
animation: _slideCurve,
|
||||
builder: (_, _) => MiniScreen(
|
||||
colorScheme: colorScheme,
|
||||
floatingBar: floatingBar,
|
||||
selected: _selected,
|
||||
previous: _previous,
|
||||
progress: _previous == null ? 1 : _slideCurve.value,
|
||||
tabAnimation: tabAnimation,
|
||||
onSelect: _select,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _MiniPage { cards, list }
|
||||
|
||||
/// A phone-shaped sketch of the home page, drawn in [colorScheme].
|
||||
///
|
||||
/// With [previous], the page is caught [progress] of the way through a switch
|
||||
/// from it, drawn as [tabAnimation] moves the pages.
|
||||
class MiniScreen extends StatelessWidget {
|
||||
const MiniScreen({
|
||||
super.key,
|
||||
required this.colorScheme,
|
||||
required this.floatingBar,
|
||||
this.selected = 0,
|
||||
this.previous,
|
||||
this.progress = 1,
|
||||
this.tabAnimation = TabAnimation.slide,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
static const int destinationCount = 4;
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final bool floatingBar;
|
||||
final int selected;
|
||||
final int? previous;
|
||||
final double progress;
|
||||
final TabAnimation tabAnimation;
|
||||
final ValueChanged<int>? onSelect;
|
||||
|
||||
static _MiniPage _pageOf(int index) =>
|
||||
index.isEven ? _MiniPage.cards : _MiniPage.list;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final unit = width / 20;
|
||||
final margin = unit * 1.5;
|
||||
final barHeight = floatingBar ? unit * 3.2 : unit * 3.6;
|
||||
final fabSize = unit * 3.2;
|
||||
final previous = this.previous;
|
||||
final hasFab = previous == null || progress > 0.5
|
||||
? selected == 0
|
||||
: previous == 0;
|
||||
final pageBottom = floatingBar ? 0.0 : barHeight;
|
||||
final direction = previous != null && previous > selected ? -1 : 1;
|
||||
Widget pageAt(int index, double offset, {double opacity = 1}) {
|
||||
return Positioned(
|
||||
left: offset,
|
||||
width: width,
|
||||
top: 0,
|
||||
bottom: pageBottom,
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(margin, margin, margin, 0),
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
maxHeight: double.infinity,
|
||||
child: _MiniPageContent(
|
||||
colorScheme: colorScheme,
|
||||
page: _pageOf(index),
|
||||
seed: index,
|
||||
unit: unit,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> switching(int previous) {
|
||||
if (tabAnimation == TabAnimation.fade) {
|
||||
return [
|
||||
pageAt(previous, 0, opacity: 1 - progress),
|
||||
pageAt(selected, 0, opacity: progress),
|
||||
];
|
||||
}
|
||||
return [
|
||||
pageAt(previous, -direction * width * progress),
|
||||
pageAt(selected, direction * width * (1 - progress)),
|
||||
];
|
||||
}
|
||||
|
||||
final destinations = _MiniDestinations(
|
||||
colorScheme: colorScheme,
|
||||
selected: selected,
|
||||
unit: unit,
|
||||
onSelect: onSelect,
|
||||
);
|
||||
return ColoredBox(
|
||||
color: colorScheme.surface,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (previous == null)
|
||||
pageAt(selected, 0)
|
||||
else
|
||||
...switching(previous),
|
||||
if (hasFab && !floatingBar)
|
||||
Positioned(
|
||||
right: margin,
|
||||
bottom: barHeight + margin,
|
||||
width: fabSize * 1.6,
|
||||
height: fabSize,
|
||||
child: _MiniBlock(
|
||||
color: colorScheme.primaryContainer,
|
||||
extent: fabSize,
|
||||
),
|
||||
),
|
||||
if (floatingBar)
|
||||
Positioned(
|
||||
left: margin,
|
||||
right: margin + (hasFab ? fabSize + unit * 0.6 : 0),
|
||||
bottom: margin,
|
||||
height: barHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: AppShape.full,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: unit,
|
||||
offset: Offset(0, unit * 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: destinations,
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: barHeight,
|
||||
child: ColoredBox(
|
||||
color: colorScheme.surfaceContainer,
|
||||
child: destinations,
|
||||
),
|
||||
),
|
||||
if (hasFab && floatingBar)
|
||||
Positioned(
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
width: fabSize,
|
||||
height: fabSize,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MiniSplitScreen extends StatelessWidget {
|
||||
const MiniSplitScreen({super.key, required this.light, required this.dark});
|
||||
|
||||
final Widget light;
|
||||
final Widget dark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
light,
|
||||
ClipPath(clipper: _DiagonalClipper(), child: dark),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiagonalClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
return Path()
|
||||
..moveTo(size.width * 0.7, 0)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..lineTo(size.width * 0.3, size.height)
|
||||
..close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_DiagonalClipper oldClipper) => false;
|
||||
}
|
||||
|
||||
class _MiniPageContent extends StatelessWidget {
|
||||
const _MiniPageContent({
|
||||
required this.colorScheme,
|
||||
required this.page,
|
||||
required this.seed,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final _MiniPage page;
|
||||
final int seed;
|
||||
final double unit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = colorScheme.surfaceContainer;
|
||||
final line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: unit,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: unit * 0.5),
|
||||
child: _MiniLine(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
width: unit * 7,
|
||||
height: unit * 1.1,
|
||||
),
|
||||
),
|
||||
...switch (page) {
|
||||
_MiniPage.cards => [
|
||||
_MiniCard(
|
||||
color: card,
|
||||
height: unit * 5,
|
||||
unit: unit,
|
||||
child: Row(
|
||||
spacing: unit,
|
||||
children: [
|
||||
_MiniDot(color: colorScheme.primary, size: unit * 2.2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: unit * 0.6,
|
||||
children: [
|
||||
_MiniLine(color: line, width: unit * 6, height: unit),
|
||||
_MiniLine(
|
||||
color: line,
|
||||
width: unit * 3.5,
|
||||
height: unit * 0.8,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
spacing: unit,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MiniCard(color: card, height: unit * 4, unit: unit),
|
||||
),
|
||||
Expanded(
|
||||
child: _MiniCard(
|
||||
color: colorScheme.secondaryContainer,
|
||||
height: unit * 4,
|
||||
unit: unit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (var i = 0; i < 3; i++)
|
||||
_MiniCard(color: card, height: unit * 4.5, unit: unit),
|
||||
],
|
||||
_MiniPage.list => [
|
||||
for (var i = 0; i < 7; i++)
|
||||
SizedBox(
|
||||
height: unit * 2.6,
|
||||
child: Row(
|
||||
spacing: unit,
|
||||
children: [
|
||||
_MiniDot(
|
||||
color: i == 0
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.secondaryContainer,
|
||||
size: unit * 2.2,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: unit * 0.5,
|
||||
children: [
|
||||
_MiniLine(
|
||||
color: line,
|
||||
width: unit * (8 - (i + seed) % 3 * 1.5),
|
||||
height: unit * 0.8,
|
||||
),
|
||||
_MiniLine(
|
||||
color: line.withValues(alpha: 0.2),
|
||||
width: unit * 4,
|
||||
height: unit * 0.6,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniDestinations extends StatelessWidget {
|
||||
const _MiniDestinations({
|
||||
required this.colorScheme,
|
||||
required this.selected,
|
||||
required this.unit,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
final int selected;
|
||||
final double unit;
|
||||
final ValueChanged<int>? onSelect;
|
||||
|
||||
Widget _buildDestination(int index) {
|
||||
if (index != selected) {
|
||||
return _MiniDot(color: colorScheme.onSurfaceVariant, size: unit * 0.9);
|
||||
}
|
||||
return Container(
|
||||
width: unit * 2.8,
|
||||
height: unit * 1.6,
|
||||
alignment: Alignment.center,
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: _MiniDot(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: unit * 0.9,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onSelect = this.onSelect;
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < MiniScreen.destinationCount; i++)
|
||||
Expanded(
|
||||
child: onSelect == null
|
||||
? Center(child: _buildDestination(i))
|
||||
: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onSelect(i),
|
||||
child: Center(child: _buildDestination(i)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniCard extends StatelessWidget {
|
||||
const _MiniCard({
|
||||
required this.color,
|
||||
required this.height,
|
||||
required this.unit,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final double height;
|
||||
final double unit;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: _MiniBlock(
|
||||
color: color,
|
||||
extent: height,
|
||||
padding: EdgeInsets.symmetric(horizontal: unit),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniBlock extends StatelessWidget {
|
||||
const _MiniBlock({
|
||||
required this.color,
|
||||
required this.extent,
|
||||
this.padding,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final double extent;
|
||||
final EdgeInsets? padding;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: ShapeDecoration(
|
||||
color: color,
|
||||
shape: AppShape.all(AppCorner.fit(extent)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniLine extends StatelessWidget {
|
||||
const _MiniLine({
|
||||
required this.color,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: ShapeDecoration(color: color, shape: AppShape.full),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniDot extends StatelessWidget {
|
||||
const _MiniDot({required this.color, required this.size});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: ShapeDecoration(color: color, shape: AppShape.circle),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,32 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'fade_box.dart';
|
||||
import 'scaffold.dart';
|
||||
import 'text.dart';
|
||||
|
||||
class Info {
|
||||
final String label;
|
||||
final IconData? iconData;
|
||||
final Glyph? glyph;
|
||||
|
||||
const Info({required this.label, this.iconData});
|
||||
const Info({required this.label, this.glyph});
|
||||
}
|
||||
|
||||
class InfoHeader extends StatelessWidget {
|
||||
final Info info;
|
||||
final List<Widget> actions;
|
||||
final EdgeInsets? padding;
|
||||
final double? space;
|
||||
|
||||
const InfoHeader({
|
||||
super.key,
|
||||
required this.info,
|
||||
this.padding,
|
||||
this.space,
|
||||
List<Widget>? actions,
|
||||
}) : actions = actions ?? const [];
|
||||
|
||||
@@ -43,9 +47,9 @@ class InfoHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
if (info.iconData != null) ...[
|
||||
Icon(
|
||||
info.iconData,
|
||||
if (info.glyph case final glyph?) ...[
|
||||
GlyphIcon(
|
||||
glyph,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -73,6 +77,7 @@ class InfoHeader extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: space ?? appBarActionSpace,
|
||||
children: [...actions],
|
||||
),
|
||||
),
|
||||
@@ -82,6 +87,8 @@ class InfoHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const commonCardIconSize = 20.0;
|
||||
|
||||
class CommonCard extends StatelessWidget {
|
||||
const CommonCard({
|
||||
super.key,
|
||||
@@ -93,6 +100,7 @@ class CommonCard extends StatelessWidget {
|
||||
this.padding,
|
||||
this.enterAnimated = false,
|
||||
this.info,
|
||||
this.infoPadding,
|
||||
this.onLongPress,
|
||||
this.shape,
|
||||
this.isError = false,
|
||||
@@ -112,6 +120,7 @@ class CommonCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
final Info? info;
|
||||
final EdgeInsets? infoPadding;
|
||||
final CommonCardType type;
|
||||
final double? radius;
|
||||
final OutlinedBorder? shape;
|
||||
@@ -207,7 +216,7 @@ class CommonCard extends StatelessWidget {
|
||||
FilledButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconSize: commonCardIconSize,
|
||||
iconColor: _buildIconColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
side: BorderSide.none,
|
||||
@@ -231,7 +240,7 @@ class CommonCard extends StatelessWidget {
|
||||
OutlinedButton.styleFrom(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
|
||||
iconSize: 20,
|
||||
iconSize: commonCardIconSize,
|
||||
iconColor: _buildIconColor(context),
|
||||
backgroundColor: _buildBackgroundColor(context),
|
||||
foregroundColor: _buildForegroundColor(context),
|
||||
@@ -256,7 +265,7 @@ class CommonCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InfoHeader(
|
||||
padding: baseInfoEdgeInsets.copyWith(bottom: 0),
|
||||
padding: infoPadding ?? baseInfoEdgeInsets.copyWith(bottom: 0),
|
||||
info: info!,
|
||||
),
|
||||
Flexible(flex: 1, child: child),
|
||||
@@ -288,8 +297,17 @@ class CommonCard extends StatelessWidget {
|
||||
}
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
final context = focusNode?.context;
|
||||
if (focusNode == null ||
|
||||
context == null ||
|
||||
if (focusNode == null || context == null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
final action = focusNode.descendants
|
||||
.where((node) => node.skipTraversal && node.canRequestFocus)
|
||||
.firstOrNull;
|
||||
if (action != null) {
|
||||
action.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (focusNode.skipTraversal ||
|
||||
context.findAncestorWidgetOfExactType<IconButton>() != null) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -307,7 +325,9 @@ class CommonCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SkipTraversalFocusNode extends FocusNode {
|
||||
/// A focus node left out of the traversal that walks between cards: a card
|
||||
/// that must not be a stop, or an action inside a card reached by arrow right.
|
||||
class SkipTraversalFocusNode extends FocusNode {
|
||||
@override
|
||||
bool get skipTraversal => true;
|
||||
}
|
||||
@@ -322,7 +342,7 @@ class _SkipTraversalScope extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SkipTraversalScopeState extends State<_SkipTraversalScope> {
|
||||
final FocusNode _focusNode = _SkipTraversalFocusNode();
|
||||
final FocusNode _focusNode = SkipTraversalFocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -346,7 +366,7 @@ class SelectIcon extends StatelessWidget {
|
||||
shape: AppShape.circle,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: const Icon(Icons.check, size: 16),
|
||||
child: const GlyphIcon(AppGlyphs.check, size: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,121 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'card.dart';
|
||||
import 'grid.dart';
|
||||
|
||||
class ColorSchemeBox extends StatelessWidget {
|
||||
final Color? primaryColor;
|
||||
final bool? isSelected;
|
||||
final void Function()? onPressed;
|
||||
final double size;
|
||||
|
||||
const ColorSchemeBox({
|
||||
super.key,
|
||||
required this.primaryColor,
|
||||
this.onPressed,
|
||||
this.isSelected,
|
||||
this.size = 48,
|
||||
});
|
||||
|
||||
static const _duration = Duration(milliseconds: 200);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: PrimaryColorBox(
|
||||
primaryColor: primaryColor,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Stack(
|
||||
children: [
|
||||
CommonCard(
|
||||
isSelected: isSelected,
|
||||
onPressed: onPressed,
|
||||
selectWidget: Container(
|
||||
final selected = isSelected ?? false;
|
||||
final ring = Theme.of(context).colorScheme.primary;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: PrimaryColorBox(
|
||||
primaryColor: primaryColor,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
shape: AppShape.circle,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: AppShape.circle,
|
||||
onTap: onPressed,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
child: const SelectIcon(),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.full,
|
||||
child: SizedBox(
|
||||
width: 72,
|
||||
height: 72,
|
||||
child: Grid(
|
||||
crossAxisCount: 2,
|
||||
children: [
|
||||
GridItem(
|
||||
mainAxisCellCount: 2,
|
||||
child: Container(color: colorScheme.primary),
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: _duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.all(selected ? 5 : 0),
|
||||
decoration: ShapeDecoration(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(
|
||||
color: selected ? ring : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
GridItem(
|
||||
mainAxisCellCount: 1,
|
||||
child: Container(color: colorScheme.secondary),
|
||||
),
|
||||
GridItem(
|
||||
mainAxisCellCount: 1,
|
||||
child: Container(color: colorScheme.tertiary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(color: colorScheme.primary),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedScale(
|
||||
duration: _duration,
|
||||
curve: Curves.easeOutBack,
|
||||
scale: selected ? 1 : 0,
|
||||
child: const SelectIcon(),
|
||||
),
|
||||
if (primaryColor == null)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: AppShape.circle,
|
||||
),
|
||||
child: GlyphIcon(
|
||||
AppGlyphs.eyedropper,
|
||||
size: size / 4,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (primaryColor == null)
|
||||
const Positioned(
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
child: Icon(Icons.colorize, size: 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/inherited.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -119,7 +120,7 @@ class _CommonExpandIconState extends State<CommonExpandIcon>
|
||||
builder: (_, child) {
|
||||
return RotationTransition(turns: _iconTurns, child: child!);
|
||||
},
|
||||
child: const Icon(Icons.expand_more),
|
||||
child: const GlyphIcon(AppGlyphs.caretDown, fill: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,9 +144,9 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
label: widget.semanticLabel,
|
||||
value: widget.semanticValue,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
decoration: ShapeDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: AppRadius.full,
|
||||
shape: AppShape.full,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:fl_clash/widgets/fade_box.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
@@ -13,28 +16,54 @@ enum NullStatusIllustration {
|
||||
rules,
|
||||
connections,
|
||||
requests,
|
||||
dns,
|
||||
ntp,
|
||||
wifi,
|
||||
apps,
|
||||
history,
|
||||
permission,
|
||||
camera,
|
||||
error,
|
||||
search,
|
||||
}
|
||||
|
||||
class NullStatusSwitcher extends StatelessWidget {
|
||||
static const _exitDuration = Duration(milliseconds: 150);
|
||||
|
||||
class NullStatusSwitcher extends StatefulWidget {
|
||||
/// Only the first load is held back; later loads keep what is on screen.
|
||||
final bool isLoading;
|
||||
final bool isEmpty;
|
||||
final bool isSearching;
|
||||
final NullStatus nullStatus;
|
||||
final Widget child;
|
||||
|
||||
const NullStatusSwitcher({
|
||||
super.key,
|
||||
this.isLoading = false,
|
||||
required this.isEmpty,
|
||||
this.isSearching = false,
|
||||
required this.nullStatus,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NullStatusSwitcher> createState() => _NullStatusSwitcherState();
|
||||
}
|
||||
|
||||
class _NullStatusSwitcherState extends State<NullStatusSwitcher> {
|
||||
static const _exitDuration = Duration(milliseconds: 150);
|
||||
|
||||
late bool _loaded = !widget.isLoading;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant NullStatusSwitcher oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_loaded = _loaded || !widget.isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: context.motionDuration(commonDuration),
|
||||
reverseDuration: context.motionDuration(_exitDuration),
|
||||
@@ -55,20 +84,28 @@ class NullStatusSwitcher extends StatelessWidget {
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: isEmpty
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.empty),
|
||||
child: nullStatus,
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.content),
|
||||
child: child,
|
||||
),
|
||||
child: switch ((widget.isEmpty, widget.isSearching)) {
|
||||
(true, true) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.noResults),
|
||||
child: NullStatus(
|
||||
label: context.appLocalizations.noSearchResults,
|
||||
illustration: NullStatusIllustration.search,
|
||||
),
|
||||
),
|
||||
(true, false) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.empty),
|
||||
child: widget.nullStatus,
|
||||
),
|
||||
(false, _) => KeyedSubtree(
|
||||
key: const ValueKey(_NullStatusSlot.content),
|
||||
child: widget.child,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NullStatusSlot { empty, content }
|
||||
enum _NullStatusSlot { empty, noResults, content }
|
||||
|
||||
class NullStatus extends StatelessWidget {
|
||||
final String label;
|
||||
@@ -88,58 +125,76 @@ class NullStatus extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final description = this.description;
|
||||
final action = this.action;
|
||||
final textTheme = context.textTheme;
|
||||
final colorScheme = context.colorScheme;
|
||||
final compact = MediaQuery.sizeOf(context).height < _compactHeight;
|
||||
return Align(
|
||||
alignment: const Alignment(0.0, -0.25),
|
||||
child: Wrap(
|
||||
direction: Axis.vertical,
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_EnterItem(
|
||||
child: RepaintBoundary(
|
||||
child: _EmptyIllustration(type: illustration),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_EnterItem(
|
||||
delay: _staggerStep,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.titleMedium?.toBold.toLight,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_EnterItem(
|
||||
delay: _staggerStep * 2,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
alignment: const Alignment(0.0, -0.2),
|
||||
child: SingleChildScrollView(
|
||||
primary: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_EnterItem(
|
||||
child: _EmptyIllustration(
|
||||
type: illustration,
|
||||
dimension: compact ? 120 : 160,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 12 : 16),
|
||||
_EnterItem(
|
||||
delay: _staggerStep,
|
||||
child: Text(
|
||||
description,
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_EnterItem(
|
||||
delay: _staggerStep * (description != null ? 3 : 2),
|
||||
child: action,
|
||||
),
|
||||
],
|
||||
],
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_EnterItem(
|
||||
delay: _staggerStep * 2,
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_EnterItem(
|
||||
delay: _staggerStep * (description != null ? 3 : 2),
|
||||
child: action,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _compactHeight = 560.0;
|
||||
const _staggerStep = Duration(milliseconds: 60);
|
||||
const _enterRise = 12.0;
|
||||
|
||||
bool _skipsEntrance(BuildContext context) {
|
||||
final route = ModalRoute.of(context);
|
||||
final routeEntering =
|
||||
route != null &&
|
||||
(route.offstage || (route.animation?.isAnimating ?? false));
|
||||
return context.disableAnimations || routeEntering;
|
||||
}
|
||||
|
||||
class _EnterItem extends StatefulWidget {
|
||||
final Duration delay;
|
||||
final Widget child;
|
||||
@@ -170,11 +225,7 @@ class _EnterItemState extends State<_EnterItem>
|
||||
if (_skipped != null) {
|
||||
return;
|
||||
}
|
||||
final route = ModalRoute.of(context);
|
||||
final routeEntering =
|
||||
route != null &&
|
||||
(route.offstage || (route.animation?.isAnimating ?? false));
|
||||
_skipped = context.disableAnimations || routeEntering;
|
||||
_skipped = _skipsEntrance(context);
|
||||
if (!_skipped!) {
|
||||
_controller.forward();
|
||||
}
|
||||
@@ -206,145 +257,452 @@ class _EnterItemState extends State<_EnterItem>
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyIllustration extends StatelessWidget {
|
||||
static const _dimension = 200.0;
|
||||
|
||||
class _EmptyIllustration extends StatefulWidget {
|
||||
final NullStatusIllustration type;
|
||||
final double dimension;
|
||||
|
||||
const _EmptyIllustration({required this.type});
|
||||
const _EmptyIllustration({required this.type, required this.dimension});
|
||||
|
||||
@override
|
||||
State<_EmptyIllustration> createState() => _EmptyIllustrationState();
|
||||
}
|
||||
|
||||
class _EmptyIllustrationState extends State<_EmptyIllustration>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static final _random = math.Random();
|
||||
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
value: 1,
|
||||
);
|
||||
final _entrance = _Entrance.values[_random.nextInt(_Entrance.values.length)];
|
||||
var _started = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_started) {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
if (!_skipsEntrance(context)) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = widget.type;
|
||||
final colorScheme = context.colorScheme;
|
||||
final (assetPath, shape, backgroundColor) = switch (type) {
|
||||
NullStatusIllustration.data => (
|
||||
'assets/images/empty/data.svg',
|
||||
MaterialShapes.square,
|
||||
colorScheme.secondaryContainer,
|
||||
),
|
||||
NullStatusIllustration.logs => (
|
||||
'assets/images/empty/log.svg',
|
||||
MaterialShapes.softBurst,
|
||||
colorScheme.primaryContainer,
|
||||
),
|
||||
NullStatusIllustration.proxies => (
|
||||
'assets/images/empty/proxy.svg',
|
||||
MaterialShapes.cookie12Sided,
|
||||
colorScheme.tertiaryContainer,
|
||||
),
|
||||
NullStatusIllustration.profile => (
|
||||
'assets/images/empty/profile.svg',
|
||||
MaterialShapes.arch,
|
||||
colorScheme.primaryContainer,
|
||||
),
|
||||
NullStatusIllustration.scripts => (
|
||||
'assets/images/empty/script.svg',
|
||||
MaterialShapes.cookie6Sided,
|
||||
colorScheme.secondaryContainer,
|
||||
),
|
||||
NullStatusIllustration.rules => (
|
||||
'assets/images/empty/rule.svg',
|
||||
MaterialShapes.flower,
|
||||
colorScheme.tertiaryContainer,
|
||||
),
|
||||
NullStatusIllustration.connections => (
|
||||
'assets/images/empty/connection.svg',
|
||||
MaterialShapes.puffyDiamond,
|
||||
colorScheme.secondaryContainer,
|
||||
),
|
||||
NullStatusIllustration.requests => (
|
||||
'assets/images/empty/request.svg',
|
||||
MaterialShapes.arrow,
|
||||
colorScheme.primaryContainer,
|
||||
),
|
||||
NullStatusIllustration.wifi => (
|
||||
'assets/images/empty/wifi.svg',
|
||||
MaterialShapes.clamShell,
|
||||
colorScheme.tertiaryContainer,
|
||||
),
|
||||
NullStatusIllustration.apps => (
|
||||
'assets/images/empty/apps.svg',
|
||||
MaterialShapes.cookie9Sided,
|
||||
colorScheme.secondaryContainer,
|
||||
),
|
||||
NullStatusIllustration.history => (
|
||||
'assets/images/empty/history.svg',
|
||||
MaterialShapes.softBoom,
|
||||
colorScheme.primaryContainer,
|
||||
),
|
||||
NullStatusIllustration.permission => (
|
||||
'assets/images/empty/permission.svg',
|
||||
MaterialShapes.circle,
|
||||
colorScheme.tertiaryContainer,
|
||||
),
|
||||
};
|
||||
return CustomPaint(
|
||||
key: ValueKey(type),
|
||||
painter: _MaterialShapePainter(shape: shape, color: backgroundColor),
|
||||
child: SvgPicture.asset(
|
||||
assetPath,
|
||||
width: _dimension,
|
||||
height: _dimension,
|
||||
colorMapper: _IllustrationColorMapper(colorScheme),
|
||||
excludeFromSemantics: true,
|
||||
final (shape, glyph) = _artOf(type);
|
||||
return RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
key: ValueKey(type),
|
||||
painter: _IllustrationPainter(
|
||||
shape: shape,
|
||||
glyph: glyph,
|
||||
palette: type == NullStatusIllustration.error
|
||||
? _IllustrationPalette.error(colorScheme)
|
||||
: _IllustrationPalette(colorScheme),
|
||||
entrance: _entrance,
|
||||
progress: _controller,
|
||||
),
|
||||
child: SizedBox.square(dimension: widget.dimension),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static (RoundedPolygon, Glyph) _artOf(
|
||||
NullStatusIllustration type,
|
||||
) => switch (type) {
|
||||
NullStatusIllustration.data => (
|
||||
MaterialShapes.cookie12Sided,
|
||||
AppGlyphs.resources,
|
||||
),
|
||||
NullStatusIllustration.logs => (
|
||||
MaterialShapes.cookie9Sided,
|
||||
AppGlyphs.logs,
|
||||
),
|
||||
NullStatusIllustration.proxies => (MaterialShapes.sunny, AppGlyphs.proxies),
|
||||
NullStatusIllustration.profile => (
|
||||
MaterialShapes.cookie7Sided,
|
||||
AppGlyphs.profiles,
|
||||
),
|
||||
NullStatusIllustration.scripts => (
|
||||
MaterialShapes.cookie6Sided,
|
||||
AppGlyphs.code,
|
||||
),
|
||||
NullStatusIllustration.rules => (
|
||||
MaterialShapes.clover8Leaf,
|
||||
AppGlyphs.rules,
|
||||
),
|
||||
NullStatusIllustration.connections => (
|
||||
MaterialShapes.cookie4Sided,
|
||||
AppGlyphs.connections,
|
||||
),
|
||||
NullStatusIllustration.requests => (
|
||||
MaterialShapes.softBurst,
|
||||
AppGlyphs.requests,
|
||||
),
|
||||
NullStatusIllustration.dns => (MaterialShapes.puffy, AppGlyphs.dns),
|
||||
NullStatusIllustration.ntp => (MaterialShapes.pentagon, AppGlyphs.clock),
|
||||
NullStatusIllustration.wifi => (
|
||||
MaterialShapes.cookie7Sided,
|
||||
AppGlyphs.wifi,
|
||||
),
|
||||
NullStatusIllustration.apps => (
|
||||
MaterialShapes.clover4Leaf,
|
||||
AppGlyphs.appsList,
|
||||
),
|
||||
NullStatusIllustration.history => (
|
||||
MaterialShapes.cookie12Sided,
|
||||
AppGlyphs.history,
|
||||
),
|
||||
NullStatusIllustration.permission => (MaterialShapes.sunny, AppGlyphs.lock),
|
||||
NullStatusIllustration.camera => (
|
||||
MaterialShapes.cookie6Sided,
|
||||
AppGlyphs.camera,
|
||||
),
|
||||
NullStatusIllustration.error => (MaterialShapes.softBurst, AppGlyphs.error),
|
||||
NullStatusIllustration.search => (MaterialShapes.flower, AppGlyphs.search),
|
||||
};
|
||||
}
|
||||
|
||||
class _MaterialShapePainter extends CustomPainter {
|
||||
class _IllustrationPalette {
|
||||
final Color shape;
|
||||
final Color ink;
|
||||
final Color highlight;
|
||||
final Color accent;
|
||||
final Color accentContainer;
|
||||
final bool sparkles;
|
||||
|
||||
_IllustrationPalette(ColorScheme colorScheme)
|
||||
: shape = colorScheme.primaryContainer,
|
||||
ink = colorScheme.onPrimaryContainer,
|
||||
highlight = colorScheme.primary,
|
||||
accent = colorScheme.tertiary,
|
||||
accentContainer = colorScheme.tertiaryContainer,
|
||||
sparkles = true;
|
||||
|
||||
_IllustrationPalette.error(ColorScheme colorScheme)
|
||||
: shape = colorScheme.errorContainer,
|
||||
ink = colorScheme.onErrorContainer,
|
||||
highlight = colorScheme.error,
|
||||
accent = colorScheme.error,
|
||||
accentContainer = colorScheme.errorContainer,
|
||||
sparkles = false;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _IllustrationPalette &&
|
||||
other.shape == shape &&
|
||||
other.ink == ink &&
|
||||
other.highlight == highlight &&
|
||||
other.accent == accent &&
|
||||
other.accentContainer == accentContainer &&
|
||||
other.sparkles == sparkles;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(shape, ink, highlight, accent, accentContainer, sparkles);
|
||||
}
|
||||
|
||||
/// Every choreography must land on the same final frame, since a skipped
|
||||
/// entrance paints that frame directly.
|
||||
enum _Entrance { spin, morph, orbit }
|
||||
|
||||
enum _Accent { sparkle, blob, dot, ring }
|
||||
|
||||
typedef _ShapePose = ({double scale, double turn, double morph, double alpha});
|
||||
typedef _GlyphPose = ({double scale, double rise, double fill, double alpha});
|
||||
typedef _AccentPose = ({double scale, double drift, double orbit, double turn});
|
||||
|
||||
class _IllustrationPainter extends CustomPainter {
|
||||
static const _shapeExtent = 0.72;
|
||||
static const _haloExtent = 0.84;
|
||||
static const _glyphExtent = 0.4;
|
||||
static const _accents = [
|
||||
(_Accent.sparkle, Offset(0.3, -0.31), 0.22),
|
||||
(_Accent.blob, Offset(-0.32, 0.3), 0.14),
|
||||
(_Accent.dot, Offset(-0.4, -0.18), 0.05),
|
||||
(_Accent.ring, Offset(0.42, 0.15), 0.075),
|
||||
];
|
||||
static final _paths = Expando<Path>();
|
||||
static final _morphs = Expando<Morph>();
|
||||
|
||||
final RoundedPolygon shape;
|
||||
final Color color;
|
||||
final Glyph glyph;
|
||||
final _IllustrationPalette palette;
|
||||
final _Entrance entrance;
|
||||
final Animation<double> progress;
|
||||
|
||||
_MaterialShapePainter({required this.shape, required this.color});
|
||||
|
||||
late final Path _path = shape.toPath();
|
||||
_IllustrationPainter({
|
||||
required this.shape,
|
||||
required this.glyph,
|
||||
required this.palette,
|
||||
required this.entrance,
|
||||
required this.progress,
|
||||
}) : super(repaint: progress);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final dimension = size.shortestSide;
|
||||
final offset = Offset(
|
||||
(size.width - dimension) / 2,
|
||||
(size.height - dimension) / 2,
|
||||
final t = progress.value;
|
||||
final side = size.shortestSide;
|
||||
final center = size.center(Offset.zero);
|
||||
final (halo, body, mark) = _poseAt(t);
|
||||
|
||||
_paintShape(
|
||||
canvas,
|
||||
halo,
|
||||
center,
|
||||
side * _haloExtent,
|
||||
palette.highlight.withValues(
|
||||
alpha: palette.highlight.a * 0.14 * halo.alpha,
|
||||
),
|
||||
);
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..isAntiAlias = true;
|
||||
_paintShape(canvas, body, center, side * _shapeExtent, palette.shape);
|
||||
if (mark.scale > 0) {
|
||||
_paintGlyph(
|
||||
canvas,
|
||||
glyph,
|
||||
center + Offset(0, side * mark.rise),
|
||||
side * _glyphExtent * mark.scale,
|
||||
mark.fill,
|
||||
palette.ink.withValues(alpha: palette.ink.a * mark.alpha),
|
||||
);
|
||||
}
|
||||
|
||||
for (final (index, (accent, offset, extent)) in _accents.indexed) {
|
||||
if (accent == _Accent.sparkle && !palette.sparkles) {
|
||||
continue;
|
||||
}
|
||||
final pose = _accentAt(t, index);
|
||||
if (pose.scale <= 0) {
|
||||
continue;
|
||||
}
|
||||
final at =
|
||||
center +
|
||||
Offset.fromDirection(offset.direction + pose.orbit, offset.distance) *
|
||||
side *
|
||||
pose.drift;
|
||||
final diameter = side * extent * pose.scale;
|
||||
switch (accent) {
|
||||
case _Accent.sparkle:
|
||||
_paintGlyph(
|
||||
canvas,
|
||||
AppGlyphs.sparkle,
|
||||
at,
|
||||
diameter,
|
||||
1,
|
||||
palette.accent,
|
||||
turn: pose.turn,
|
||||
);
|
||||
case _Accent.blob:
|
||||
_paintPath(
|
||||
canvas,
|
||||
_paths[MaterialShapes.cookie4Sided] ??= MaterialShapes.cookie4Sided
|
||||
.toPath(),
|
||||
at,
|
||||
diameter,
|
||||
math.pi / 12 + pose.turn,
|
||||
palette.accentContainer,
|
||||
);
|
||||
case _Accent.dot:
|
||||
canvas.drawCircle(at, diameter / 2, Paint()..color = palette.accent);
|
||||
case _Accent.ring:
|
||||
final width = diameter * 0.28;
|
||||
canvas.drawCircle(
|
||||
at,
|
||||
(diameter - width) / 2,
|
||||
Paint()
|
||||
..color = palette.highlight
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = width,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(_ShapePose, _ShapePose, _GlyphPose) _poseAt(double t) {
|
||||
switch (entrance) {
|
||||
case _Entrance.spin:
|
||||
_ShapePose spun(double settle) => (
|
||||
scale: lerpDouble(0.6, 1, settle)!,
|
||||
turn: -math.pi / 3 * (1 - settle),
|
||||
morph: 1,
|
||||
alpha: 1,
|
||||
);
|
||||
return (
|
||||
spun(_phase(t, 0.08, 0.75, Curves.easeOutBack)),
|
||||
spun(_phase(t, 0, 0.65, Curves.easeOutBack)),
|
||||
(
|
||||
scale:
|
||||
lerpDouble(0.5, 1, _phase(t, 0.1, 0.55, Curves.easeOutBack))! +
|
||||
math.sin(math.pi * _phase(t, 0.55, 0.85)) * 0.08,
|
||||
rise: 0,
|
||||
fill: _phase(t, 0.2, 0.6, Curves.easeInOutCubic),
|
||||
alpha: _phase(t, 0.1, 0.3),
|
||||
),
|
||||
);
|
||||
case _Entrance.morph:
|
||||
_ShapePose morphed(double begin, double end) {
|
||||
final morph = _phase(t, begin, end, Curves.easeInOutCubic);
|
||||
return (
|
||||
scale: lerpDouble(
|
||||
0.5,
|
||||
1,
|
||||
_phase(t, begin, begin + 0.5, Curves.easeOutBack),
|
||||
)!,
|
||||
turn: math.pi / 2 * (1 - morph),
|
||||
morph: morph,
|
||||
alpha: 1,
|
||||
);
|
||||
}
|
||||
final rise = _phase(t, 0.2, 0.6, Curves.easeOutBack);
|
||||
return (
|
||||
morphed(0.15, 0.8),
|
||||
morphed(0.05, 0.65),
|
||||
(
|
||||
scale: lerpDouble(0.8, 1, rise)!,
|
||||
rise: 0.14 * (1 - rise),
|
||||
fill: _phase(t, 0.3, 0.7, Curves.easeInOutCubic),
|
||||
alpha: _phase(t, 0.2, 0.35),
|
||||
),
|
||||
);
|
||||
case _Entrance.orbit:
|
||||
final ripple = _phase(t, 0.2, 0.75, Curves.easeOutCubic);
|
||||
return (
|
||||
(
|
||||
scale: lerpDouble(_shapeExtent / _haloExtent, 1, ripple)!,
|
||||
turn: 0,
|
||||
morph: 1,
|
||||
alpha: ripple,
|
||||
),
|
||||
(
|
||||
scale: lerpDouble(
|
||||
0.3,
|
||||
1,
|
||||
_phase(t, 0, 0.6, const ElasticOutCurve(0.6)),
|
||||
)!,
|
||||
turn: 0,
|
||||
morph: 1,
|
||||
alpha: 1,
|
||||
),
|
||||
(
|
||||
scale: _phase(t, 0.15, 0.7, const ElasticOutCurve(0.5)),
|
||||
rise: 0,
|
||||
fill: _phase(t, 0.3, 0.7, Curves.easeInOutCubic),
|
||||
alpha: _phase(t, 0.15, 0.25),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_AccentPose _accentAt(double t, int index) {
|
||||
switch (entrance) {
|
||||
case _Entrance.spin:
|
||||
final begin = 0.4 + index * 0.08;
|
||||
final enter = _phase(t, begin, begin + 0.35, Curves.easeOutBack);
|
||||
return (
|
||||
scale: enter,
|
||||
drift: lerpDouble(0.7, 1, enter)!,
|
||||
orbit: 0,
|
||||
turn: -math.pi / 4 * (1 - enter),
|
||||
);
|
||||
case _Entrance.morph:
|
||||
final begin = 0.45 + (_accents.length - 1 - index) * 0.07;
|
||||
final enter = _phase(t, begin, begin + 0.3, Curves.easeOutBack);
|
||||
return (
|
||||
scale: enter,
|
||||
drift: 1,
|
||||
orbit: 0,
|
||||
turn: -math.pi / 2 * (1 - enter),
|
||||
);
|
||||
case _Entrance.orbit:
|
||||
final begin = 0.3 + index * 0.1;
|
||||
final travel = _phase(t, begin, begin + 0.4, Curves.easeOutCubic);
|
||||
return (
|
||||
scale: _phase(t, begin, begin + 0.3, Curves.easeOutBack),
|
||||
drift: lerpDouble(0.55, 1, travel)!,
|
||||
orbit: -math.pi * 0.7 * (1 - travel),
|
||||
turn: -math.pi / 2 * (1 - travel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static double _phase(
|
||||
double t,
|
||||
double begin,
|
||||
double end, [
|
||||
Curve curve = Curves.linear,
|
||||
]) => curve.transform(((t - begin) / (end - begin)).clamp(0.0, 1.0));
|
||||
|
||||
void _paintShape(
|
||||
Canvas canvas,
|
||||
_ShapePose pose,
|
||||
Offset center,
|
||||
double extent,
|
||||
Color color,
|
||||
) {
|
||||
final path = pose.morph >= 1
|
||||
? _paths[shape] ??= shape.toPath()
|
||||
: (_morphs[shape] ??= Morph(
|
||||
MaterialShapes.circle,
|
||||
shape,
|
||||
)).toPath(progress: pose.morph);
|
||||
_paintPath(canvas, path, center, extent * pose.scale, pose.turn, color);
|
||||
}
|
||||
|
||||
void _paintPath(
|
||||
Canvas canvas,
|
||||
Path path,
|
||||
Offset center,
|
||||
double extent,
|
||||
double rotation,
|
||||
Color color,
|
||||
) {
|
||||
canvas.save();
|
||||
canvas.translate(offset.dx, offset.dy);
|
||||
canvas.scale(dimension);
|
||||
canvas.drawPath(_path, paint);
|
||||
canvas.translate(center.dx, center.dy);
|
||||
canvas.rotate(rotation);
|
||||
canvas.scale(extent);
|
||||
canvas.translate(-0.5, -0.5);
|
||||
canvas.drawPath(path, Paint()..color = color);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
void _paintGlyph(
|
||||
Canvas canvas,
|
||||
Glyph glyph,
|
||||
Offset center,
|
||||
double extent,
|
||||
double fill,
|
||||
Color color, {
|
||||
double turn = 0,
|
||||
}) {
|
||||
canvas.save();
|
||||
canvas.translate(center.dx, center.dy);
|
||||
canvas.rotate(turn);
|
||||
canvas.translate(-extent / 2, -extent / 2);
|
||||
GlyphPainter(
|
||||
glyph: glyph,
|
||||
fill: fill,
|
||||
color: color,
|
||||
).paint(canvas, Size.square(extent));
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MaterialShapePainter oldDelegate) =>
|
||||
oldDelegate.shape != shape || oldDelegate.color != color;
|
||||
}
|
||||
|
||||
class _IllustrationColorMapper extends ColorMapper {
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _IllustrationColorMapper(this.colorScheme);
|
||||
|
||||
@override
|
||||
Color substitute(
|
||||
String? id,
|
||||
String elementName,
|
||||
String attributeName,
|
||||
Color color,
|
||||
) => switch (color.toARGB32()) {
|
||||
0xFFE8DEF8 => colorScheme.secondaryContainer,
|
||||
0xFF6750A4 => colorScheme.primary,
|
||||
0xFFFDF7FF => colorScheme.surface,
|
||||
0xFFC4C7C5 => colorScheme.outlineVariant,
|
||||
_ => color,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is _IllustrationColorMapper && other.colorScheme == colorScheme;
|
||||
|
||||
@override
|
||||
int get hashCode => colorScheme.hashCode;
|
||||
bool shouldRepaint(covariant _IllustrationPainter oldDelegate) =>
|
||||
oldDelegate.shape != shape ||
|
||||
oldDelegate.glyph != glyph ||
|
||||
oldDelegate.palette != palette ||
|
||||
oldDelegate.entrance != entrance ||
|
||||
oldDelegate.progress != progress;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
|
||||
import 'color_scheme_box.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
@immutable
|
||||
const _selectionRingInset = 4.0;
|
||||
const _trackHeight = 20.0;
|
||||
const _thumbRadius = 14.0;
|
||||
const _trackTone = 60.0;
|
||||
const _previewInset = 8.0;
|
||||
|
||||
class Palette extends StatefulWidget {
|
||||
const Palette({super.key, required this.controller});
|
||||
@@ -57,30 +58,57 @@ class _PaletteState extends State<Palette> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final maxChroma = Hct.from(_hue, 200, _trackTone).chroma;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.controller,
|
||||
builder: (_, _, _) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HueSlider(hue: _hue, onChanged: _onHueChanged),
|
||||
const SizedBox(height: 16),
|
||||
_ChromaSlider(
|
||||
hue: _hue,
|
||||
chroma: _chroma,
|
||||
_GradientSlider(
|
||||
value: _hue,
|
||||
max: 360,
|
||||
thumbColor: Color(Hct.from(_hue, 100, _trackTone).toInt()),
|
||||
colors: [
|
||||
for (var hue = 0; hue <= 360; hue += 10)
|
||||
Color(Hct.from(hue.toDouble(), 100, _trackTone).toInt()),
|
||||
],
|
||||
onChanged: _onHueChanged,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_GradientSlider(
|
||||
value: _chroma.clamp(0, maxChroma),
|
||||
max: maxChroma,
|
||||
thumbColor: Color(Hct.from(_hue, _chroma, _trackTone).toInt()),
|
||||
colors: [
|
||||
for (var i = 0; i <= 24; i++)
|
||||
Color(
|
||||
Hct.from(_hue, maxChroma * i / 24, _trackTone).toInt(),
|
||||
),
|
||||
],
|
||||
onChanged: _onChromaChanged,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_ToneGrid(
|
||||
const SizedBox(height: 16),
|
||||
_ToneStrip(
|
||||
hue: _hue,
|
||||
chroma: _chroma,
|
||||
selectedTone: _tone,
|
||||
onToneSelected: _onToneSelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InfoHeader(info: Info(label: context.appLocalizations.preview)),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
context.appLocalizations.preview,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
PrimaryColorBox(
|
||||
primaryColor: widget.controller.value,
|
||||
child: const _ColorSchemePreview(),
|
||||
@@ -93,64 +121,51 @@ class _PaletteState extends State<Palette> {
|
||||
}
|
||||
}
|
||||
|
||||
class _HueSlider extends StatelessWidget {
|
||||
const _HueSlider({required this.hue, required this.onChanged});
|
||||
|
||||
final double hue;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliderTheme(
|
||||
data: SliderDefaultsM3(context).copyWith(
|
||||
trackShape: _HueTrackShape(),
|
||||
trackHeight: 24,
|
||||
thumbSize: const WidgetStatePropertyAll(Size(6.0, 48.0)),
|
||||
thumbColor: Color(Hct.from(hue, 100, 80).toInt()),
|
||||
),
|
||||
child: Slider(
|
||||
padding: EdgeInsets.zero,
|
||||
value: hue,
|
||||
min: 0,
|
||||
max: 360,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChromaSlider extends StatelessWidget {
|
||||
const _ChromaSlider({
|
||||
required this.hue,
|
||||
required this.chroma,
|
||||
class _GradientSlider extends StatelessWidget {
|
||||
const _GradientSlider({
|
||||
required this.value,
|
||||
required this.max,
|
||||
required this.thumbColor,
|
||||
required this.colors,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final double hue;
|
||||
final double chroma;
|
||||
final double value;
|
||||
final double max;
|
||||
final Color thumbColor;
|
||||
final List<Color> colors;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliderTheme(
|
||||
data: SliderDefaultsM3(context).copyWith(
|
||||
trackShape: _ChromaTrackShape(hue: hue),
|
||||
trackHeight: 24,
|
||||
thumbSize: const WidgetStatePropertyAll(Size(6.0, 48.0)),
|
||||
thumbColor: Color(Hct.from(hue, chroma, 80).toInt()),
|
||||
trackShape: _GradientTrackShape(
|
||||
colors: colors,
|
||||
outlineColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
trackHeight: _trackHeight,
|
||||
thumbShape: _ColorThumbShape(color: thumbColor),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
padding: EdgeInsets.zero,
|
||||
value: chroma.clamp(0, 10),
|
||||
value: value,
|
||||
min: 0,
|
||||
max: 10,
|
||||
max: max,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HueTrackShape extends SliderTrackShape {
|
||||
class _GradientTrackShape extends SliderTrackShape {
|
||||
const _GradientTrackShape({required this.colors, required this.outlineColor});
|
||||
|
||||
final List<Color> colors;
|
||||
final Color outlineColor;
|
||||
|
||||
// The thumb travels between the pill's end caps so it never overhangs them.
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
required RenderBox parentBox,
|
||||
@@ -159,12 +174,11 @@ class _HueTrackShape extends SliderTrackShape {
|
||||
bool isEnabled = false,
|
||||
bool isDiscrete = false,
|
||||
}) {
|
||||
final trackHeight = sliderTheme.trackHeight ?? 24;
|
||||
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
|
||||
final trackHeight = sliderTheme.trackHeight ?? _trackHeight;
|
||||
return Rect.fromLTWH(
|
||||
offset.dx,
|
||||
trackTop,
|
||||
parentBox.size.width,
|
||||
offset.dx + _thumbRadius,
|
||||
offset.dy + (parentBox.size.height - trackHeight) / 2,
|
||||
parentBox.size.width - _thumbRadius * 2,
|
||||
trackHeight,
|
||||
);
|
||||
}
|
||||
@@ -182,107 +196,78 @@ class _HueTrackShape extends SliderTrackShape {
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
}) {
|
||||
final rect = getPreferredRect(
|
||||
final valueRect = getPreferredRect(
|
||||
parentBox: parentBox,
|
||||
offset: offset,
|
||||
sliderTheme: sliderTheme,
|
||||
isEnabled: isEnabled,
|
||||
isDiscrete: isDiscrete,
|
||||
);
|
||||
final colors = <Color>[];
|
||||
for (int i = 0; i <= 360; i += 10) {
|
||||
colors.add(Color(Hct.from(i.toDouble(), 100, 60).toInt()));
|
||||
}
|
||||
final shader = LinearGradient(colors: colors).createShader(rect);
|
||||
final rect = Rect.fromLTRB(
|
||||
valueRect.left - _thumbRadius,
|
||||
valueRect.top,
|
||||
valueRect.right + _thumbRadius,
|
||||
valueRect.bottom,
|
||||
);
|
||||
final shape = RSuperellipse.fromRectAndRadius(
|
||||
rect,
|
||||
const Radius.circular(AppCorner.full),
|
||||
);
|
||||
context.canvas.drawRSuperellipse(shape, Paint()..shader = shader);
|
||||
_paintTrackHighlight(context.canvas, rect);
|
||||
final canvas = context.canvas;
|
||||
canvas.drawRSuperellipse(
|
||||
shape,
|
||||
Paint()..shader = LinearGradient(colors: colors).createShader(valueRect),
|
||||
);
|
||||
canvas.drawRSuperellipse(
|
||||
shape,
|
||||
Paint()
|
||||
..color = outlineColor.withValues(alpha: 0.6)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChromaTrackShape extends SliderTrackShape {
|
||||
const _ChromaTrackShape({required this.hue});
|
||||
class _ColorThumbShape extends SliderComponentShape {
|
||||
const _ColorThumbShape({required this.color});
|
||||
|
||||
final double hue;
|
||||
final Color color;
|
||||
|
||||
static const _ringWidth = 3.0;
|
||||
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
required RenderBox parentBox,
|
||||
Offset offset = Offset.zero,
|
||||
required SliderThemeData sliderTheme,
|
||||
bool isEnabled = false,
|
||||
bool isDiscrete = false,
|
||||
}) {
|
||||
final trackHeight = sliderTheme.trackHeight ?? 24;
|
||||
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
|
||||
return Rect.fromLTWH(
|
||||
offset.dx,
|
||||
trackTop,
|
||||
parentBox.size.width,
|
||||
trackHeight,
|
||||
);
|
||||
}
|
||||
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
|
||||
const Size.fromRadius(_thumbRadius);
|
||||
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset offset, {
|
||||
Offset center, {
|
||||
required Animation<double> activationAnimation,
|
||||
required Animation<double> enableAnimation,
|
||||
required bool isDiscrete,
|
||||
required TextPainter labelPainter,
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
required Animation<double> enableAnimation,
|
||||
required TextDirection textDirection,
|
||||
required Offset thumbCenter,
|
||||
Offset? secondaryOffset,
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
required double value,
|
||||
required double textScaleFactor,
|
||||
required Size sizeWithOverflow,
|
||||
}) {
|
||||
final rect = getPreferredRect(
|
||||
parentBox: parentBox,
|
||||
offset: offset,
|
||||
sliderTheme: sliderTheme,
|
||||
isEnabled: isEnabled,
|
||||
isDiscrete: isDiscrete,
|
||||
final canvas = context.canvas;
|
||||
final radius = _thumbRadius + activationAnimation.value * 2;
|
||||
canvas.drawCircle(
|
||||
center.translate(0, 1),
|
||||
radius,
|
||||
Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.28)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.5),
|
||||
);
|
||||
final colors = <Color>[];
|
||||
for (int i = 0; i <= 49; i++) {
|
||||
colors.add(Color(Hct.from(hue, (i / 49) * 150, 60).toInt()));
|
||||
}
|
||||
final shader = LinearGradient(colors: colors).createShader(rect);
|
||||
final shape = RSuperellipse.fromRectAndRadius(
|
||||
rect,
|
||||
const Radius.circular(AppCorner.full),
|
||||
);
|
||||
context.canvas.drawRSuperellipse(shape, Paint()..shader = shader);
|
||||
_paintTrackHighlight(context.canvas, rect);
|
||||
canvas.drawCircle(center, radius, Paint()..color = Colors.white);
|
||||
canvas.drawCircle(center, radius - _ringWidth, Paint()..color = color);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintTrackHighlight(Canvas canvas, Rect rect) {
|
||||
final shape = RSuperellipse.fromRectAndRadius(
|
||||
rect.deflate(1),
|
||||
const Radius.circular(AppCorner.full),
|
||||
);
|
||||
canvas.drawRSuperellipse(
|
||||
shape,
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.35)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(rect.left + 12, rect.bottom - 2),
|
||||
Offset(rect.right - 12, rect.bottom - 2),
|
||||
Paint()
|
||||
..color = Colors.black.withValues(alpha: 0.12)
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
}
|
||||
|
||||
class _ToneGrid extends StatelessWidget {
|
||||
const _ToneGrid({
|
||||
class _ToneStrip extends StatelessWidget {
|
||||
const _ToneStrip({
|
||||
required this.hue,
|
||||
required this.chroma,
|
||||
required this.selectedTone,
|
||||
@@ -295,80 +280,107 @@ class _ToneGrid extends StatelessWidget {
|
||||
final ValueChanged<double> onToneSelected;
|
||||
|
||||
static const _tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||||
static const _spacing = 8.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedBorderColor = Theme.of(context).colorScheme.primary;
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final columns = min(
|
||||
max((constraints.maxWidth / 40).floor(), 1),
|
||||
_tones.length,
|
||||
);
|
||||
final itemWidth =
|
||||
(constraints.maxWidth - (columns - 1) * _spacing) / columns;
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
runSpacing: _spacing,
|
||||
children: _tones.map((tone) {
|
||||
final color = Color(Hct.from(hue, chroma, tone.toDouble()).toInt());
|
||||
final isSelected = tone == selectedTone.round();
|
||||
final textColor = tone <= 50 ? Colors.white : Colors.black;
|
||||
return GestureDetector(
|
||||
onTap: () => onToneSelected(tone.toDouble()),
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
height: itemWidth,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: ShapeDecoration(
|
||||
color: color,
|
||||
shape: AppShape.sm,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$tone',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
return Container(
|
||||
height: 44,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: const ShapeDecoration(shape: AppShape.md),
|
||||
foregroundDecoration: ShapeDecoration(
|
||||
shape: AppShape.md.copyWith(
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final tone in _tones)
|
||||
Expanded(
|
||||
child: _ToneCell(
|
||||
tone: tone,
|
||||
color: Color(Hct.from(hue, chroma, tone.toDouble()).toInt()),
|
||||
isSelected: tone == selectedTone.round(),
|
||||
onSelected: () => onToneSelected(tone.toDouble()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToneCell extends StatefulWidget {
|
||||
const _ToneCell({
|
||||
required this.tone,
|
||||
required this.color,
|
||||
required this.isSelected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final int tone;
|
||||
final Color color;
|
||||
final bool isSelected;
|
||||
final VoidCallback onSelected;
|
||||
|
||||
@override
|
||||
State<_ToneCell> createState() => _ToneCellState();
|
||||
}
|
||||
|
||||
class _ToneCellState extends State<_ToneCell> {
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final foregroundColor = widget.tone <= 50 ? Colors.white : Colors.black;
|
||||
final showRing = widget.isSelected || _isFocused;
|
||||
return Material(
|
||||
color: widget.color,
|
||||
child: InkWell(
|
||||
onTap: widget.onSelected,
|
||||
onFocusChange: (value) => setState(() => _isFocused = value),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (showRing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: AppShape.full.copyWith(
|
||||
side: BorderSide(
|
||||
color: foregroundColor.withValues(
|
||||
alpha: widget.isSelected ? 0.9 : 0.5,
|
||||
),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned.fill(
|
||||
top: -_selectionRingInset,
|
||||
right: -_selectionRingInset,
|
||||
bottom: -_selectionRingInset,
|
||||
left: -_selectionRingInset,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: ShapeDecoration(
|
||||
shape: RoundedSuperellipseBorder(
|
||||
borderRadius: AppRadius.all(
|
||||
AppCorner.sm + _selectionRingInset,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: selectedBorderColor,
|
||||
width: _selectionRingInset,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${widget.tone}',
|
||||
style: TextStyle(
|
||||
color: foregroundColor.withValues(
|
||||
alpha: widget.isSelected ? 1 : 0.72,
|
||||
),
|
||||
fontSize: 11,
|
||||
fontWeight: widget.isSelected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -376,62 +388,72 @@ class _ToneGrid extends StatelessWidget {
|
||||
class _ColorSchemePreview extends StatelessWidget {
|
||||
const _ColorSchemePreview();
|
||||
|
||||
static const _spacing = 8.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final roles = [
|
||||
(colorScheme.primary, colorScheme.onPrimary, 'Primary'),
|
||||
(colorScheme.secondary, colorScheme.onSecondary, 'Secondary'),
|
||||
(colorScheme.tertiary, colorScheme.onTertiary, 'Tertiary'),
|
||||
(colorScheme.error, colorScheme.onError, 'Error'),
|
||||
(colorScheme.surface, colorScheme.onSurface, 'Surface'),
|
||||
(
|
||||
colorScheme.primary,
|
||||
colorScheme.onPrimary,
|
||||
colorScheme.primaryContainer,
|
||||
colorScheme.onPrimaryContainer,
|
||||
'Primary\nCont.',
|
||||
'Primary',
|
||||
),
|
||||
(
|
||||
colorScheme.secondary,
|
||||
colorScheme.onSecondary,
|
||||
colorScheme.secondaryContainer,
|
||||
colorScheme.onSecondaryContainer,
|
||||
'Secondary\nCont.',
|
||||
'Secondary',
|
||||
),
|
||||
(
|
||||
colorScheme.tertiary,
|
||||
colorScheme.onTertiary,
|
||||
colorScheme.tertiaryContainer,
|
||||
colorScheme.onTertiaryContainer,
|
||||
'Tertiary\nCont.',
|
||||
'Tertiary',
|
||||
),
|
||||
];
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final columns = min(
|
||||
max((constraints.maxWidth / 68).floor(), 1),
|
||||
roles.length,
|
||||
);
|
||||
final itemWidth =
|
||||
(constraints.maxWidth - (columns - 1) * _spacing) / columns;
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
runSpacing: _spacing,
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_previewInset),
|
||||
decoration: ShapeDecoration(
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: AppShape.lg.copyWith(
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) => Row(
|
||||
spacing: min(_previewInset, constraints.maxWidth / 8),
|
||||
children: [
|
||||
for (final (bg, fg, label) in roles)
|
||||
Container(
|
||||
width: itemWidth,
|
||||
height: 44,
|
||||
decoration: ShapeDecoration(color: bg, shape: AppShape.sm),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontSize: 9),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
for (final (color, onColor, container, label) in roles)
|
||||
Expanded(
|
||||
child: ClipRSuperellipse(
|
||||
borderRadius: AppRadius.all(AppCorner.lg - _previewInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
height: 44,
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: onColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(height: 24, color: container),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import 'card.dart';
|
||||
@@ -26,8 +27,10 @@ class SettingInfoCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(child: Icon(info.iconData)),
|
||||
const SizedBox(width: 8),
|
||||
if (info.glyph case final glyph?) ...[
|
||||
Flexible(child: GlyphIcon(glyph)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(info.label, style: context.textTheme.bodyMedium),
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ class CommonMinFilledButtonTheme extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
).merge(FilledButtonTheme.of(context).style),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
@@ -35,7 +35,7 @@ class CommonMinIconButtonTheme extends StatelessWidget {
|
||||
visualDensity: VisualDensity.compact,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
iconSize: 20.ap,
|
||||
),
|
||||
).merge(IconButtonTheme.of(context).style),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
@@ -1023,7 +1023,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
|
||||
@@ -62,6 +62,7 @@ dependencies:
|
||||
material_color_utilities: ^0.13.0
|
||||
material_new_shapes: ^1.0.0
|
||||
flutter_svg: ^2.1.0
|
||||
path_parsing: ^1.1.0
|
||||
flutter_cache_manager: ^3.4.1
|
||||
crypto: ^3.0.3
|
||||
yaml: ^3.1.3
|
||||
@@ -104,7 +105,6 @@ flutter:
|
||||
- assets/fonts/
|
||||
- assets/images/
|
||||
- assets/images/avatar/
|
||||
- assets/images/empty/
|
||||
- assets/images/services/
|
||||
- path: assets/images/tray/unix/
|
||||
platforms: [ linux ]
|
||||
@@ -119,9 +119,6 @@ flutter:
|
||||
- family: Twemoji
|
||||
fonts:
|
||||
- asset: assets/fonts/Twemoji.Mozilla.ttf
|
||||
- family: Icons
|
||||
fonts:
|
||||
- asset: assets/fonts/Icons.ttf
|
||||
flutter_intl:
|
||||
enabled: true
|
||||
class_name: AppLocalizations
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/widgets/theme.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
ShapeBorder? _shapeOf(WidgetTester tester, Finder button) => tester
|
||||
.widget<Material>(
|
||||
find.descendant(of: button, matching: find.byType(Material)).first,
|
||||
)
|
||||
.shape;
|
||||
|
||||
void main() {
|
||||
testWidgets('pill buttons take the superellipse capsule, not the stadium', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(useMaterial3: true).withAppShapes,
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
TextButton(onPressed: () {}, child: const Text('text')),
|
||||
OutlinedButton(onPressed: () {}, child: const Text('outlined')),
|
||||
CommonMinFilledButtonTheme(
|
||||
child: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('filled'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (final type in [TextButton, OutlinedButton, FilledButton]) {
|
||||
expect(
|
||||
_shapeOf(tester, find.byType(type)),
|
||||
isA<RoundedSuperellipseBorder>().having(
|
||||
(shape) => shape.borderRadius,
|
||||
'borderRadius',
|
||||
AppRadius.full,
|
||||
),
|
||||
reason: '$type',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
extension GlyphFinders on CommonFinders {
|
||||
Finder byGlyph(Glyph glyph) => byWidgetPredicate(
|
||||
(widget) => widget is GlyphIcon && widget.glyph == glyph,
|
||||
description: 'GlyphIcon($glyph)',
|
||||
);
|
||||
|
||||
Finder widgetWithGlyph(Type widgetType, Glyph glyph) =>
|
||||
ancestor(of: byGlyph(glyph), matching: byType(widgetType));
|
||||
}
|
||||
@@ -43,10 +43,7 @@ class TestApp extends StatelessWidget {
|
||||
if (setTheme) {
|
||||
globalState.theme = CommonTheme.of(context, 1);
|
||||
}
|
||||
// ignore: deprecated_member_use
|
||||
return MaterialUiCompatibilityBridge(
|
||||
child: IconTheme(data: Theme.of(context).iconTheme, child: child!),
|
||||
);
|
||||
return child!;
|
||||
},
|
||||
home: homeBuilder(child),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
Widget _icon({required bool filled, IconThemeData? theme}) {
|
||||
final icon = AnimatedGlyph(glyph: AppGlyphs.profiles, filled: filled);
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: theme == null ? icon : IconTheme(data: theme, child: icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
CustomPaint _paint(WidgetTester tester) => tester.widget<CustomPaint>(
|
||||
find.descendant(
|
||||
of: find.byType(AnimatedGlyph),
|
||||
matching: find.byType(CustomPaint),
|
||||
),
|
||||
);
|
||||
|
||||
GlyphPainter _painter(WidgetTester tester) =>
|
||||
_paint(tester).painter! as GlyphPainter;
|
||||
|
||||
double _fill(WidgetTester tester) => _painter(tester).fill;
|
||||
|
||||
double _scale(WidgetTester tester) {
|
||||
final transform = tester.widget<Transform>(
|
||||
find.descendant(
|
||||
of: find.byType(AnimatedGlyph),
|
||||
matching: find.byType(Transform),
|
||||
),
|
||||
);
|
||||
return transform.transform.getMaxScaleOnAxis();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('rests on the outline and on the fill', (tester) async {
|
||||
await tester.pumpWidget(_icon(filled: false));
|
||||
expect(_fill(tester), 0);
|
||||
|
||||
await tester.pumpWidget(_icon(filled: true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fill(tester), 1);
|
||||
expect(_scale(tester), 1);
|
||||
});
|
||||
|
||||
testWidgets('filling runs over time with a pop', (tester) async {
|
||||
await tester.pumpWidget(_icon(filled: false));
|
||||
await tester.pumpWidget(_icon(filled: true));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
final early = _fill(tester);
|
||||
expect(early, allOf(greaterThan(0), lessThan(0.5)));
|
||||
expect(_scale(tester), greaterThan(1));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
expect(_fill(tester), greaterThan(early));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fill(tester), 1);
|
||||
expect(_scale(tester), 1);
|
||||
});
|
||||
|
||||
testWidgets('emptying drains the fill without the pop', (tester) async {
|
||||
await tester.pumpWidget(_icon(filled: true));
|
||||
await tester.pumpWidget(_icon(filled: false));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(_fill(tester), allOf(greaterThan(0), lessThan(1)));
|
||||
expect(_scale(tester), 1);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fill(tester), 0);
|
||||
});
|
||||
|
||||
testWidgets('reversing midway drains from where the fill got to', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_icon(filled: false));
|
||||
await tester.pumpWidget(_icon(filled: true));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final reached = _fill(tester);
|
||||
final popped = _scale(tester);
|
||||
expect(popped, greaterThan(1));
|
||||
|
||||
await tester.pumpWidget(_icon(filled: false));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(_fill(tester), allOf(greaterThan(0), lessThan(reached)));
|
||||
expect(_scale(tester), closeTo(popped, 0.02));
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
expect(_fill(tester), 0);
|
||||
expect(_scale(tester), greaterThan(1));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(_scale(tester), 1);
|
||||
});
|
||||
|
||||
testWidgets('takes its size and color from the icon theme', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_icon(
|
||||
filled: false,
|
||||
theme: const IconThemeData(
|
||||
size: 32,
|
||||
color: Color(0xFF3366CC),
|
||||
opacity: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(_paint(tester).size, const Size.square(32));
|
||||
expect(
|
||||
_painter(tester).color,
|
||||
const Color(0xFF3366CC).withValues(alpha: 0.5),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
Future<bool> _mirrored(
|
||||
WidgetTester tester,
|
||||
Glyph glyph,
|
||||
TextDirection direction,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: direction,
|
||||
child: Center(child: GlyphIcon(glyph)),
|
||||
),
|
||||
);
|
||||
final transform = find.descendant(
|
||||
of: find.byType(GlyphIcon),
|
||||
matching: find.byType(Transform),
|
||||
);
|
||||
if (transform.evaluate().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return tester.widget<Transform>(transform).transform.entry(0, 0) < 0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('mirrors in right-to-left text when the glyph asks', (
|
||||
tester,
|
||||
) async {
|
||||
final sidebar = AppGlyphs.sidebar(0);
|
||||
|
||||
expect(await _mirrored(tester, sidebar, TextDirection.rtl), isTrue);
|
||||
expect(await _mirrored(tester, sidebar, TextDirection.ltr), isFalse);
|
||||
expect(
|
||||
await _mirrored(tester, AppGlyphs.target, TextDirection.rtl),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('its own size, color and fill win over the icon theme', (
|
||||
tester,
|
||||
) async {
|
||||
const themeColor = Color(0xFF0000FF);
|
||||
const ownColor = Color(0xFFFF0000);
|
||||
await tester.pumpWidget(
|
||||
const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: IconTheme(
|
||||
data: IconThemeData(size: 24, color: themeColor, fill: 1),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GlyphIcon(AppGlyphs.target),
|
||||
GlyphIcon(AppGlyphs.target, size: 16, color: ownColor, fill: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final painters = tester
|
||||
.widgetList<CustomPaint>(
|
||||
find.descendant(
|
||||
of: find.byType(GlyphIcon),
|
||||
matching: find.byType(CustomPaint),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
expect(painters.map((paint) => paint.size), const [
|
||||
Size.square(24),
|
||||
Size.square(16),
|
||||
]);
|
||||
expect(
|
||||
painters.map((paint) => (paint.painter! as GlyphPainter).color),
|
||||
const [themeColor, ownColor],
|
||||
);
|
||||
expect(
|
||||
painters.map((paint) => (paint.painter! as GlyphPainter).fill),
|
||||
const [1, 0],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps its size inside a box forced larger, as Icon does', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.tight(const Size.square(48)),
|
||||
child: const GlyphIcon(AppGlyphs.target),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final paint = find.descendant(
|
||||
of: find.byType(GlyphIcon),
|
||||
matching: find.byType(CustomPaint),
|
||||
);
|
||||
expect(tester.getSize(find.byType(GlyphIcon)), const Size.square(48));
|
||||
expect(tester.getSize(paint), const Size.square(24));
|
||||
expect(tester.getCenter(paint), tester.getCenter(find.byType(GlyphIcon)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fl_clash/icons/icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _scale = 3;
|
||||
const _side = 24 * _scale;
|
||||
|
||||
final _glyphs = {
|
||||
'dashboard': AppGlyphs.dashboard,
|
||||
'proxies': AppGlyphs.proxies,
|
||||
'profiles': AppGlyphs.profiles,
|
||||
'requests': AppGlyphs.requests,
|
||||
'history': AppGlyphs.history,
|
||||
'connections': AppGlyphs.connections,
|
||||
'dns': AppGlyphs.dns,
|
||||
'resources': AppGlyphs.resources,
|
||||
'logs': AppGlyphs.logs,
|
||||
'tools': AppGlyphs.tools,
|
||||
'target': AppGlyphs.target,
|
||||
'arrowBack': AppGlyphs.arrowBack,
|
||||
'chevronBack': AppGlyphs.chevronBack,
|
||||
'chevronForward': AppGlyphs.chevronForward,
|
||||
'chevronDown': AppGlyphs.chevronDown,
|
||||
'chevronUp': AppGlyphs.chevronUp,
|
||||
'caretDown': AppGlyphs.caretDown,
|
||||
'arrowUp': AppGlyphs.arrowUp,
|
||||
'arrowDown': AppGlyphs.arrowDown,
|
||||
'scrollToTop': AppGlyphs.scrollToTop,
|
||||
'openExternal': AppGlyphs.openExternal,
|
||||
'undo': AppGlyphs.undo,
|
||||
'redo': AppGlyphs.redo,
|
||||
'sync': AppGlyphs.sync,
|
||||
'refresh': AppGlyphs.refresh,
|
||||
'reset': AppGlyphs.reset,
|
||||
'export': AppGlyphs.export,
|
||||
'upload': AppGlyphs.upload,
|
||||
'add': AppGlyphs.add,
|
||||
'addCircle': AppGlyphs.addCircle,
|
||||
'remove': AppGlyphs.remove,
|
||||
'close': AppGlyphs.close,
|
||||
'check': AppGlyphs.check,
|
||||
'checkDouble': AppGlyphs.checkDouble,
|
||||
'checkCircle': AppGlyphs.checkCircle,
|
||||
'block': AppGlyphs.block,
|
||||
'info': AppGlyphs.info,
|
||||
'warning': AppGlyphs.warning,
|
||||
'error': AppGlyphs.error,
|
||||
'more': AppGlyphs.more,
|
||||
'moreCircle': AppGlyphs.moreCircle,
|
||||
'dragHandle': AppGlyphs.dragHandle,
|
||||
'sort': AppGlyphs.sort,
|
||||
'sortAlpha': AppGlyphs.sortAlpha,
|
||||
'textShort': AppGlyphs.textShort,
|
||||
'wrap': AppGlyphs.wrap,
|
||||
'listAdd': AppGlyphs.listAdd,
|
||||
'edit': AppGlyphs.edit,
|
||||
'compose': AppGlyphs.compose,
|
||||
'delete': AppGlyphs.delete,
|
||||
'clearAll': AppGlyphs.clearAll,
|
||||
'copy': AppGlyphs.copy,
|
||||
'cut': AppGlyphs.cut,
|
||||
'paste': AppGlyphs.paste,
|
||||
'selectAll': AppGlyphs.selectAll,
|
||||
'deselect': AppGlyphs.deselect,
|
||||
'search': AppGlyphs.search,
|
||||
'findReplace': AppGlyphs.findReplace,
|
||||
'save': AppGlyphs.save,
|
||||
'importFile': AppGlyphs.importFile,
|
||||
'cloudDownload': AppGlyphs.cloudDownload,
|
||||
'cloudSync': AppGlyphs.cloudSync,
|
||||
'link': AppGlyphs.link,
|
||||
'send': AppGlyphs.send,
|
||||
'rules': AppGlyphs.rules,
|
||||
'code': AppGlyphs.code,
|
||||
'settings': AppGlyphs.settings,
|
||||
'sliders': AppGlyphs.sliders,
|
||||
'wrench': AppGlyphs.wrench,
|
||||
'toggle': AppGlyphs.toggle,
|
||||
'pin': AppGlyphs.pin,
|
||||
'eye': AppGlyphs.eye,
|
||||
'eyeOff': AppGlyphs.eyeOff,
|
||||
'lock': AppGlyphs.lock,
|
||||
'lockOpen': AppGlyphs.lockOpen,
|
||||
'key': AppGlyphs.key,
|
||||
'password': AppGlyphs.password,
|
||||
'account': AppGlyphs.account,
|
||||
'sparkle': AppGlyphs.sparkle,
|
||||
'bolt': AppGlyphs.bolt,
|
||||
'locate': AppGlyphs.locate,
|
||||
'star': AppGlyphs.star,
|
||||
'puzzle': AppGlyphs.puzzle,
|
||||
'customize': AppGlyphs.customize,
|
||||
'layers': AppGlyphs.layers,
|
||||
'sun': AppGlyphs.sun,
|
||||
'moon': AppGlyphs.moon,
|
||||
'themeAuto': AppGlyphs.themeAuto,
|
||||
'pureBlack': AppGlyphs.pureBlack,
|
||||
'palette': AppGlyphs.palette,
|
||||
'paintbrush': AppGlyphs.paintbrush,
|
||||
'eyedropper': AppGlyphs.eyedropper,
|
||||
'motion': AppGlyphs.motion,
|
||||
'blur': AppGlyphs.blur,
|
||||
'textSize': AppGlyphs.textSize,
|
||||
'language': AppGlyphs.language,
|
||||
'keyboard': AppGlyphs.keyboard,
|
||||
'photos': AppGlyphs.photos,
|
||||
'camera': AppGlyphs.camera,
|
||||
'qrCode': AppGlyphs.qrCode,
|
||||
'torch': AppGlyphs.torch,
|
||||
'torchOff': AppGlyphs.torchOff,
|
||||
'torchAuto': AppGlyphs.torchAuto,
|
||||
'vpn': AppGlyphs.vpn,
|
||||
'shuffle': AppGlyphs.shuffle,
|
||||
'split': AppGlyphs.split,
|
||||
'speed': AppGlyphs.speed,
|
||||
'dataUsage': AppGlyphs.dataUsage,
|
||||
'memory': AppGlyphs.memory,
|
||||
'cpu': AppGlyphs.cpu,
|
||||
'devices': AppGlyphs.devices,
|
||||
'broom': AppGlyphs.broom,
|
||||
'wifi': AppGlyphs.wifi,
|
||||
'wifiOff': AppGlyphs.wifiOff,
|
||||
'networkCheck': AppGlyphs.networkCheck,
|
||||
'gavel': AppGlyphs.gavel,
|
||||
'appsList': AppGlyphs.appsList,
|
||||
'layoutList': AppGlyphs.layoutList,
|
||||
'layoutTabs': AppGlyphs.layoutTabs,
|
||||
'columnsThree': AppGlyphs.columnsThree,
|
||||
'columnsTwo': AppGlyphs.columnsTwo,
|
||||
'columnsOne': AppGlyphs.columnsOne,
|
||||
'collapsed sidebar': AppGlyphs.sidebar(0),
|
||||
'expanded sidebar': AppGlyphs.sidebar(1),
|
||||
'play': AppGlyphs.playPause(0),
|
||||
'pause': AppGlyphs.playPause(1),
|
||||
'play pause midway': AppGlyphs.playPause(0.5),
|
||||
'play pause overshoot': AppGlyphs.playPause(1.1),
|
||||
};
|
||||
|
||||
Future<Uint8List> _render(
|
||||
Glyph glyph,
|
||||
double fill, {
|
||||
Color color = const Color(0xFF000000),
|
||||
}) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder)..scale(_scale.toDouble());
|
||||
GlyphPainter(
|
||||
glyph: glyph,
|
||||
fill: fill,
|
||||
color: color,
|
||||
).paint(canvas, const Size.square(24));
|
||||
final image = await recorder.endRecording().toImage(_side, _side);
|
||||
final bytes = await image.toByteData();
|
||||
image.dispose();
|
||||
return bytes!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
int _alpha(Uint8List pixels, int pixel) => pixels[pixel * 4 + 3];
|
||||
|
||||
int _alphaAt(Uint8List pixels, double x, double y) =>
|
||||
_alpha(pixels, (y * _scale).floor() * _side + (x * _scale).floor());
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const ring = Glyph([
|
||||
GlyphCircle(12, 12, 8),
|
||||
GlyphPolyline([
|
||||
GlyphVertex(12, 4),
|
||||
GlyphVertex(12, 20),
|
||||
], role: GlyphRole.detail),
|
||||
]);
|
||||
|
||||
test('a body fills inward from its outline', () async {
|
||||
final pixels = await _render(ring, 0.5);
|
||||
|
||||
expect(_alphaAt(pixels, 18, 12), 255, reason: 'filled near the outline');
|
||||
expect(_alphaAt(pixels, 14, 12), 0, reason: 'still hollow at the core');
|
||||
});
|
||||
|
||||
test('a detail thins away, then returns as a cutout', () async {
|
||||
final thinning = await _render(ring, 0.25);
|
||||
expect(_alphaAt(thinning, 12, 14), 255, reason: 'still stroked');
|
||||
expect(_alphaAt(thinning, 11.4, 14), lessThan(128), reason: 'thinner');
|
||||
|
||||
final returning = await _render(ring, 0.75);
|
||||
expect(_alphaAt(returning, 12, 18), 0, reason: 'cut out of the fill');
|
||||
expect(_alphaAt(returning, 11.4, 18), greaterThan(128), reason: 'narrower');
|
||||
});
|
||||
|
||||
test('a line gains weight as the glyph fills', () async {
|
||||
const bar = Glyph([
|
||||
GlyphPolyline([
|
||||
GlyphVertex(4, 12),
|
||||
GlyphVertex(20, 12),
|
||||
], role: GlyphRole.line),
|
||||
]);
|
||||
final outline = await _render(bar, 0);
|
||||
final filled = await _render(bar, 1);
|
||||
|
||||
expect(_alphaAt(outline, 12, 13.1), 0);
|
||||
expect(_alphaAt(filled, 12, 13.1), greaterThan(0));
|
||||
});
|
||||
|
||||
for (final MapEntry(key: name, value: glyph) in _glyphs.entries) {
|
||||
test('$name fills into a different shape than its outline', () async {
|
||||
final outline = await _render(glyph, 0);
|
||||
final filled = await _render(glyph, 1);
|
||||
|
||||
expect(outline.any((channel) => channel != 0), isTrue);
|
||||
if (glyph.shapes.any((shape) => !shape.solid)) {
|
||||
expect(filled, isNot(outline));
|
||||
} else {
|
||||
expect(filled, outline, reason: 'solid shapes are already filled');
|
||||
}
|
||||
});
|
||||
|
||||
test('$name shows no detail midway', () async {
|
||||
final bodies = Glyph([
|
||||
for (final shape in glyph.shapes)
|
||||
if (shape.role != GlyphRole.detail) shape,
|
||||
]);
|
||||
|
||||
expect(await _render(glyph, 0.5), await _render(bodies, 0.5));
|
||||
});
|
||||
|
||||
test('$name stays even under a translucent color', () async {
|
||||
for (final fill in [0.0, 0.25, 0.75, 1.0]) {
|
||||
final pixels = await _render(
|
||||
glyph,
|
||||
fill,
|
||||
color: const Color(0x80000000),
|
||||
);
|
||||
final alphas = [
|
||||
for (var channel = 3; channel < pixels.length; channel += 4)
|
||||
pixels[channel],
|
||||
];
|
||||
expect(alphas.reduce(math.max), lessThanOrEqualTo(0x80));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _filledButton = RegExp(
|
||||
r'\b(?:IconButton\.filled(?:Tonal)?|FilledButton(?:\.tonal|\.icon|\.tonalIcon)?'
|
||||
r'|(?:Common)?FloatingActionButton(?:\.extended|\.small|\.large)?)\(',
|
||||
);
|
||||
final _glyphIcon = RegExp(r'\bGlyphIcon\(');
|
||||
final _filled = RegExp(r'\bfill:\s*1\b');
|
||||
|
||||
int _closingParen(String source, int open) {
|
||||
var depth = 0;
|
||||
String? quote;
|
||||
for (var i = open; i < source.length; i++) {
|
||||
final char = source[i];
|
||||
if (quote != null) {
|
||||
if (char == r'\') {
|
||||
i++;
|
||||
} else if (char == quote) {
|
||||
quote = null;
|
||||
}
|
||||
} else if (char == "'" || char == '"') {
|
||||
quote = char;
|
||||
} else if (char == '(') {
|
||||
depth++;
|
||||
} else if (char == ')' && --depth == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return source.length - 1;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('glyphs inside filled buttons are drawn filled', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final entity in Directory('lib').listSync(recursive: true)) {
|
||||
if (entity is! File ||
|
||||
!entity.path.endsWith('.dart') ||
|
||||
entity.path.contains('/generated/')) {
|
||||
continue;
|
||||
}
|
||||
final source = entity.readAsStringSync();
|
||||
for (final button in _filledButton.allMatches(source)) {
|
||||
final end = _closingParen(source, button.end - 1);
|
||||
final arguments = source.substring(button.end, end);
|
||||
for (final glyph in _glyphIcon.allMatches(arguments)) {
|
||||
final start = button.end + glyph.start;
|
||||
final call = source.substring(
|
||||
start,
|
||||
_closingParen(source, button.end + glyph.end - 1) + 1,
|
||||
);
|
||||
if (!_filled.hasMatch(call)) {
|
||||
final line = '\n'.allMatches(source.substring(0, start)).length;
|
||||
offenders.add(
|
||||
'${entity.path}:${line + 1} — ${button[0]} holds $call; '
|
||||
'pass fill: 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _materialIcon = RegExp(r'\b(?:Animated)?Icons\.\w+');
|
||||
|
||||
void main() {
|
||||
test('lib draws its icons from AppGlyphs, not Material icons', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final entity in Directory('lib').listSync(recursive: true)) {
|
||||
if (entity is! File ||
|
||||
!entity.path.endsWith('.dart') ||
|
||||
entity.path.contains('/generated/')) {
|
||||
continue;
|
||||
}
|
||||
final lines = entity.readAsLinesSync();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final match = _materialIcon.firstMatch(lines[i]);
|
||||
if (match != null) {
|
||||
offenders.add(
|
||||
'${entity.path}:${i + 1} — ${match[0]} is a Material icon; '
|
||||
'draw it in AppGlyphs and show it with GlyphIcon.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final _circularApi = RegExp(
|
||||
r'\b(?:RoundedRectangleBorder|ContinuousRectangleBorder|StadiumBorder'
|
||||
r'|ClipRRect|drawRRect|drawDRRect|addRRect|clipRRect)\b|\bRRect\.',
|
||||
);
|
||||
|
||||
final _radiusCall = RegExp(r'\b(BoxDecoration|InkWell|InkResponse)\(');
|
||||
|
||||
bool _hasTopLevelBorderRadius(String source, int open) {
|
||||
var depth = 0;
|
||||
for (var i = open; i < source.length; i++) {
|
||||
switch (source[i]) {
|
||||
case '(' || '[' || '{':
|
||||
depth++;
|
||||
case ')' || ']' || '}':
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
if (depth == 1 &&
|
||||
source.startsWith('borderRadius:', i) &&
|
||||
!RegExp(r'\w').hasMatch(source[i - 1])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int _lineOf(String source, int offset) =>
|
||||
'\n'.allMatches(source.substring(0, offset)).length + 1;
|
||||
|
||||
void main() {
|
||||
test('lib rounds corners with superellipses, not circular arcs', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final entity in Directory('lib').listSync(recursive: true)) {
|
||||
if (entity is! File ||
|
||||
!entity.path.endsWith('.dart') ||
|
||||
entity.path.contains('/generated/')) {
|
||||
continue;
|
||||
}
|
||||
final source = entity.readAsStringSync();
|
||||
final reported = <int>{};
|
||||
for (final match in _circularApi.allMatches(source)) {
|
||||
if (!reported.add(_lineOf(source, match.start))) {
|
||||
continue;
|
||||
}
|
||||
offenders.add(
|
||||
'${entity.path}:${_lineOf(source, match.start)} — ${match[0]} '
|
||||
'draws circular corners; use the superellipse API or AppShape.',
|
||||
);
|
||||
}
|
||||
for (final match in _radiusCall.allMatches(source)) {
|
||||
if (_hasTopLevelBorderRadius(source, match.end - 1)) {
|
||||
offenders.add(
|
||||
'${entity.path}:${_lineOf(source, match.start)} — ${match[1]} '
|
||||
'with borderRadius rounds with circular arcs; use a '
|
||||
'ShapeDecoration or customBorder with an AppShape.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_color_utilities/hct/hct.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
@@ -50,6 +51,32 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('pure black darkens only the dark scheme and keeps surface order', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final light = container.read(genColorSchemeProvider(Brightness.light));
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update((state) => state.copyWith(pureBlack: true));
|
||||
|
||||
expect(container.read(genColorSchemeProvider(Brightness.light)), light);
|
||||
|
||||
final dark = container.read(genColorSchemeProvider(Brightness.dark));
|
||||
final tones = [
|
||||
dark.surfaceContainerLowest,
|
||||
dark.surface,
|
||||
dark.surfaceDim,
|
||||
dark.surfaceContainerLow,
|
||||
dark.surfaceContainer,
|
||||
dark.surfaceContainerHigh,
|
||||
dark.surfaceContainerHighest,
|
||||
].map((color) => Hct.fromInt(color.toARGB32()).tone).toList();
|
||||
|
||||
expect(dark.surface, Colors.black);
|
||||
expect(tones, orderedEquals([...tones]..sort()));
|
||||
expect(tones[3], greaterThan(tones[1]));
|
||||
});
|
||||
|
||||
test('genColorScheme falls back to the accent color without a seed', () {
|
||||
const accent = Color(0xFFFF0000);
|
||||
final container = ProviderContainer();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/feature.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
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/state.dart';
|
||||
import 'package:fl_clash/views/theme.dart';
|
||||
import 'package:fl_clash/widgets/widgets.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -21,13 +26,13 @@ void main() {
|
||||
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
|
||||
);
|
||||
globalState.container = container;
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1400, 1400);
|
||||
container.read(viewSizeProvider.notifier).value = const Size(1400, 2400);
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
Future<void> pumpThemeView(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1400);
|
||||
tester.view.physicalSize = const Size(1400, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
@@ -67,50 +72,142 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('pure black', () {
|
||||
testWidgets('toggles both ways', (tester) async {
|
||||
group('primary color', () {
|
||||
testWidgets('blocks the back gesture only while a color is removable', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpThemeView(tester);
|
||||
RoutePopDisposition disposition() {
|
||||
return ModalRoute.of(
|
||||
tester.element(find.byType(ThemeView)),
|
||||
)!.popDisposition;
|
||||
}
|
||||
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
|
||||
await tester.longPress(find.byType(ColorSchemeBox).at(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), RoutePopDisposition.doNotPop);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(disposition(), isNot(RoutePopDisposition.doNotPop));
|
||||
});
|
||||
});
|
||||
|
||||
group('sidebar blur', () {
|
||||
testWidgets('stays hidden while the feature is off', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(find.text('Sidebar blur'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows a working toggle only on supported platforms', (
|
||||
tester,
|
||||
) async {
|
||||
feature = const Feature(sidebarBlur: true);
|
||||
addTearDown(() => feature = const Feature());
|
||||
await pumpThemeView(tester);
|
||||
final toggle = find.text('Sidebar blur');
|
||||
|
||||
expect(readTheme().sidebarBlur, isTrue);
|
||||
if (!Platform.isMacOS && !Platform.isWindows) {
|
||||
expect(toggle, findsNothing);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(toggle, findsOneWidget);
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().sidebarBlur, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('pure black', () {
|
||||
testWidgets('switches both ways', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
final toggle = find.byType(Switch).first;
|
||||
|
||||
expect(readTheme().pureBlack, isFalse);
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.tap(find.text('Pure black'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().pureBlack, isTrue);
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.tap(find.text('Standard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().pureBlack, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('home navigation', () {
|
||||
AppSettingProps readSetting() => container.read(appSettingProvider);
|
||||
|
||||
testWidgets('picks a floating or docked bottom bar', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readSetting().floatingNavigationBar, isTrue);
|
||||
|
||||
await tester.tap(find.text('Docked'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readSetting().floatingNavigationBar, isFalse);
|
||||
|
||||
await tester.tap(find.text('Floating'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readSetting().floatingNavigationBar, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('picks a sliding or fading tab switch', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readSetting().tabAnimation, TabAnimation.slide);
|
||||
|
||||
await tester.tap(find.text('Fade'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readSetting().tabAnimation, TabAnimation.fade);
|
||||
|
||||
await tester.tap(find.text('Slide'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(readSetting().tabAnimation, TabAnimation.slide);
|
||||
});
|
||||
});
|
||||
|
||||
group('text scale', () {
|
||||
testWidgets('is disabled until its toggle is enabled', (tester) async {
|
||||
testWidgets('follows the system until custom is picked', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(readTheme().textScale.enable, isFalse);
|
||||
expect(tester.widget<Slider>(find.byType(Slider)).onChanged, isNull);
|
||||
|
||||
await tester.tap(find.byType(Switch).last);
|
||||
await tester.tap(find.text('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(readTheme().textScale.enable, isTrue);
|
||||
expect(tester.widget<Slider>(find.byType(Slider)).onChanged, isNotNull);
|
||||
|
||||
await tester.tap(find.text('Follow system'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(readTheme().textScale.enable, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the slider writes a new scale once enabled', (tester) async {
|
||||
testWidgets('writes the scale only when the drag ends', (tester) async {
|
||||
await pumpThemeView(tester);
|
||||
await tester.tap(find.byType(Switch).last);
|
||||
await tester.tap(find.text('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
final before = readTheme().textScale.scale;
|
||||
|
||||
final slider = find.byType(Slider);
|
||||
expect(slider, findsOneWidget);
|
||||
await tester.drag(slider, const Offset(120, 0));
|
||||
await tester.pumpAndSettle();
|
||||
final gesture = await tester.startGesture(tester.getCenter(slider));
|
||||
await gesture.moveBy(const Offset(120, 0));
|
||||
await tester.pump();
|
||||
expect(readTheme().textScale.scale, before);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(readTheme().textScale.scale, isNot(before));
|
||||
});
|
||||
|
||||
testWidgets('renders the scale as a rounded percentage', (tester) async {
|
||||
testWidgets('resets a custom scale to 100%', (tester) async {
|
||||
container
|
||||
.read(themeSettingProvider.notifier)
|
||||
.update(
|
||||
@@ -120,6 +217,12 @@ void main() {
|
||||
await pumpThemeView(tester);
|
||||
|
||||
expect(find.text('120%'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Reset').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(readTheme().textScale.scale, 1);
|
||||
expect(find.text('100%'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,14 +140,14 @@ void main() {
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
);
|
||||
final decoration = decoratedBox.decoration as BoxDecoration;
|
||||
final decoration = decoratedBox.decoration as ShapeDecoration;
|
||||
final customPaint = find.descendant(
|
||||
of: find.byType(CommonCircleLoading),
|
||||
matching: find.byType(CustomPaint),
|
||||
);
|
||||
|
||||
expect(decoration.color, colorScheme.primaryContainer);
|
||||
expect(decoration.borderRadius, AppRadius.full);
|
||||
expect(decoration.shape, AppShape.full);
|
||||
expect(tester.getSize(customPaint), const Size.square(32));
|
||||
expect(
|
||||
tester.getSize(find.byType(CommonCircleLoading)),
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:fl_clash/widgets/null_status.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _reducedMotion = MediaQueryData(disableAnimations: true);
|
||||
|
||||
Widget _switcher({required bool isEmpty, MediaQueryData? mediaQuery}) {
|
||||
Widget _switcher({
|
||||
required bool isEmpty,
|
||||
bool isLoading = false,
|
||||
bool isSearching = false,
|
||||
MediaQueryData? mediaQuery,
|
||||
}) {
|
||||
final switcher = NullStatusSwitcher(
|
||||
isLoading: isLoading,
|
||||
isEmpty: isEmpty,
|
||||
isSearching: isSearching,
|
||||
nullStatus: const NullStatus(label: 'Nothing here'),
|
||||
child: const Text('content', key: ValueKey('content')),
|
||||
);
|
||||
return MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
home: mediaQuery == null
|
||||
? switcher
|
||||
: MediaQuery(data: mediaQuery, child: switcher),
|
||||
@@ -18,9 +31,7 @@ Widget _switcher({required bool isEmpty, MediaQueryData? mediaQuery}) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders every semantic illustration with an M3E background', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('renders every semantic illustration', (tester) async {
|
||||
for (final illustration in NullStatusIllustration.values) {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -31,11 +42,53 @@ void main() {
|
||||
|
||||
final background = find.byKey(ValueKey(illustration));
|
||||
expect(background, findsOneWidget);
|
||||
expect(tester.getSize(background), const Size.square(200));
|
||||
expect(tester.getSize(background), const Size.square(160));
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shrinks the illustration on a short screen', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: NullStatus(label: 'Nothing here')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey(NullStatusIllustration.data))),
|
||||
const Size.square(120),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('scrolls instead of overflowing a short box', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 180,
|
||||
child: NullStatus(
|
||||
label: 'Nothing here',
|
||||
description: 'A description long enough to wrap onto more lines',
|
||||
action: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.drag(find.byType(NullStatus), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Retry').hitTestable(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps data as the default illustration', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: NullStatus(label: 'Nothing here')),
|
||||
@@ -136,6 +189,7 @@ void main() {
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Nothing here'), findsOneWidget);
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('fades between the empty state and content', (tester) async {
|
||||
@@ -156,6 +210,28 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a search that matches nothing has its own empty state', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_switcher(isEmpty: true, isSearching: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(AppLocalizations.current.noSearchResults), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey(NullStatusIllustration.search)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Nothing here'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(_switcher(isEmpty: false, isSearching: true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('content')), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(_switcher(isEmpty: true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Nothing here'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('exits the empty state faster than it enters', (tester) async {
|
||||
await tester.pumpWidget(_switcher(isEmpty: true));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -169,6 +245,45 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('shows neither side while the first load is pending', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_switcher(isEmpty: true, isLoading: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NullStatus), findsNothing);
|
||||
expect(find.byKey(const ValueKey('content')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('reveals the first loaded content without a transition', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_switcher(isEmpty: true, isLoading: true));
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(_switcher(isEmpty: false));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(NullStatus), findsNothing);
|
||||
final fades = find.ancestor(
|
||||
of: find.byKey(const ValueKey('content')),
|
||||
matching: find.byType(FadeTransition),
|
||||
);
|
||||
for (final fade in tester.widgetList<FadeTransition>(fades)) {
|
||||
expect(fade.opacity.value, 1);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('keeps the shown side when a later load starts', (tester) async {
|
||||
await tester.pumpWidget(_switcher(isEmpty: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpWidget(_switcher(isEmpty: false, isLoading: true));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey('content')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('gives the content the full body size', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
|
||||
@@ -28,6 +28,7 @@ const _groupFloors = <String, double>{
|
||||
'manager': 68.0,
|
||||
'views': 66.0,
|
||||
'enum': 86.0,
|
||||
'icons': 90.0,
|
||||
'pages': 71.0,
|
||||
'plugins': 67.0,
|
||||
'lib': 20.0,
|
||||
|
||||