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
This commit is contained in:
chen08209
2026-09-25 03:57:04 +08:00
parent 46fb7fee04
commit ee6a49713a
55 changed files with 5965 additions and 909 deletions
Binary file not shown.
-9
View File
@@ -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

-5
View File
@@ -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

-5
View File
@@ -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

-4
View File
@@ -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

-10
View File
@@ -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

-5
View File
@@ -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

-14
View File
@@ -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

-6
View File
@@ -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

-6
View File
@@ -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

-7
View File
@@ -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

-9
View File
@@ -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

-5
View File
@@ -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

+24 -17
View File
@@ -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!,
);
+37 -21
View File
@@ -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;
}
}
-1
View File
@@ -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';
+1 -1
View File
@@ -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,
);
-5
View File
@@ -1,5 +0,0 @@
import 'package:material_ui/material_ui.dart';
class IconsExt {
static const IconData target = IconData(0xe900, fontFamily: 'Icons');
}
+20 -1
View File
@@ -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));
+6 -6
View File
@@ -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;
+76
View File
@@ -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),
);
},
);
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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;
}
+225
View File
@@ -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;
}
}
+38
View File
@@ -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;
}
}
+137
View File
@@ -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;
+6
View File
@@ -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';
+12 -8
View File
@@ -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,
),
),
);
}
+23 -6
View File
@@ -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,
);
}
+574 -247
View File
@@ -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),
),
],
),
],
),
],
),
+743
View File
@@ -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),
);
}
}
+33 -13
View File
@@ -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),
),
);
}
+91 -48
View File
@@ -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),
),
],
);
},
);
},
),
),
),
);
+2 -1
View File
@@ -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),
);
}
}
+2 -2
View File
@@ -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,
+534 -176
View File
@@ -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;
}
+258 -236
View File
@@ -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),
],
),
),
),
],
);
},
),
),
);
}
}
+5 -2
View File
@@ -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),
),
+2 -2
View File
@@ -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,
);
+1 -1
View File
@@ -1023,7 +1023,7 @@ packages:
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
dependency: "direct main"
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
+1 -4
View File
@@ -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
+48
View File
@@ -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',
);
}
});
}
+12
View File
@@ -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));
}
+1 -4
View File
@@ -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),
);
+118
View File
@@ -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),
);
});
}
+108
View File
@@ -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)));
});
}
+245
View File
@@ -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));
}
});
}
}
+67
View File
@@ -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'));
});
}
+31
View File
@@ -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'));
});
}
+71
View File
@@ -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'));
});
}
+27
View File
@@ -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();
+118 -15
View File
@@ -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);
});
});
}
+2 -2
View File
@@ -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)),
+120 -5
View File
@@ -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(
+1
View File
@@ -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,