Files
FlClash/lib/views/theme_preview.dart
T
chen08209 5eafd7ad38 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 17:21:43 +08:00

851 lines
24 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
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: generateSection(
items: [
for (final choice in choices)
DecorationListItem(
leading: ExcludeSemantics(child: choice.pictogram),
title: Text(choice.label),
isSelected: choice.value == value,
trailing: choice.value == value
? const SelectionCheck()
: null,
onPressed: () => onChanged(choice.value),
),
],
),
),
],
);
}
}
/// 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) {
return SizedBox(
height: _height,
width: _height * _screenSize.width / _shownHeight,
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 liquidGlass = ref.watch(
themeSettingProvider.select(
(state) => state.liquidGlass != LiquidGlassMode.none,
),
);
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.raisedSurfaceLevel.colorOf(context.colorScheme),
shape: AppShape.xxl,
),
child: SizedBox(
width: _phoneWidth,
child: AspectRatio(
aspectRatio: 9 / 17,
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, liquidGlass)),
animation: _slideCurve,
builder: (_, _) => MiniScreen(
colorScheme: colorScheme,
floatingBar: floatingBar,
liquidGlass: liquidGlass,
selected: _selected,
previous: _previous,
progress: _previous == null ? 1 : _slideCurve.value,
tabAnimation: tabAnimation,
onSelect: _select,
),
),
),
),
),
),
),
);
}
}
enum _MiniPage { dashboard, proxies, profiles, tools }
/// 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.liquidGlass = false,
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;
/// Draws a floating bar as glass; a docked one stays a fill, as the app's
/// does.
final bool liquidGlass;
final int selected;
final int? previous;
final double progress;
final TabAnimation tabAnimation;
final ValueChanged<int>? onSelect;
@override
Widget build(BuildContext context) {
final sketch = SizedBox.expand(
child: CustomPaint(
painter: _MiniScreenPainter(
colorScheme: colorScheme,
floatingBar: floatingBar,
liquidGlass: liquidGlass,
selected: selected,
previous: previous,
progress: progress,
tabAnimation: tabAnimation,
),
),
);
final onSelect = this.onSelect;
if (onSelect == null) {
return sketch;
}
return LayoutBuilder(
builder: (_, constraints) {
final geometry = _MiniGeometry(
constraints.biggest,
floatingBar: floatingBar,
);
return Stack(
children: [
sketch,
Positioned.fromRect(
rect: geometry.bar,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinationCount; i++)
Expanded(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelect(i),
),
),
),
],
),
),
],
);
},
);
}
}
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 _MiniGeometry {
_MiniGeometry(this.size, {required bool floatingBar})
: unit = size.width / 20 {
margin = unit * 1.5;
final barHeight = floatingBar ? unit * 3.2 : unit * 3.6;
pageBottom = floatingBar ? 0 : barHeight;
bar = floatingBar
? Rect.fromLTWH(
margin,
size.height - margin - barHeight,
size.width - margin * 2,
barHeight,
)
: Rect.fromLTWH(0, size.height - barHeight, size.width, barHeight);
}
final Size size;
final double unit;
late final double margin;
late final double pageBottom;
late final Rect bar;
}
class _MiniScreenPainter extends CustomPainter {
const _MiniScreenPainter({
required this.colorScheme,
required this.floatingBar,
required this.liquidGlass,
required this.selected,
required this.previous,
required this.progress,
required this.tabAnimation,
});
final ColorScheme colorScheme;
final bool floatingBar;
final bool liquidGlass;
final int selected;
final int? previous;
final double progress;
final TabAnimation tabAnimation;
bool get _glass => floatingBar && liquidGlass;
static _MiniPage _pageOf(int index) =>
_MiniPage.values[index % _MiniPage.values.length];
static void _fill(
Canvas canvas,
Rect rect,
Color color,
ShapeBorder shape, {
List<BoxShadow>? shadows,
}) {
ShapeDecoration(color: color, shape: shape, shadows: shadows)
.createBoxPainter(() {})
.paint(canvas, rect.topLeft, ImageConfiguration(size: rect.size));
}
@override
void paint(Canvas canvas, Size size) {
final geometry = _MiniGeometry(size, floatingBar: floatingBar);
_paintPages(canvas, geometry);
final unit = geometry.unit;
final dock = SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme);
final shadow = BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.12),
blurRadius: unit,
offset: Offset(0, unit * 0.3),
);
if (_glass) {
_paintGlassBar(canvas, geometry, shadow);
} else if (floatingBar) {
_fill(canvas, geometry.bar, dock, AppShape.full, shadows: [shadow]);
} else {
canvas.drawRect(geometry.bar, Paint()..color = dock);
}
_paintDestinations(canvas, geometry.bar, unit);
}
void _paintPages(Canvas canvas, _MiniGeometry geometry) {
final size = geometry.size;
canvas.drawRect(
Offset.zero & size,
Paint()..color = SurfaceLevel.page.colorOf(colorScheme),
);
canvas.save();
canvas.clipRect(Offset.zero & size, doAntiAlias: false);
final previous = this.previous;
if (previous == null) {
_paintPage(canvas, geometry, selected, 0);
} else if (tabAnimation == TabAnimation.fade) {
_paintPage(canvas, geometry, previous, 0, opacity: 1 - progress);
_paintPage(canvas, geometry, selected, 0, opacity: progress);
} else {
final direction = previous > selected ? -1 : 1;
_paintPage(
canvas,
geometry,
previous,
-direction * size.width * progress,
);
_paintPage(
canvas,
geometry,
selected,
direction * size.width * (1 - progress),
);
}
canvas.restore();
}
/// The page frosted under a tint, ringed with light and casting its shadow
/// only outside, after what [LiquidGlass] does at full size.
void _paintGlassBar(Canvas canvas, _MiniGeometry geometry, BoxShadow shadow) {
final bar = geometry.bar;
final unit = geometry.unit;
final path = AppShape.full.getOuterPath(bar);
final style = LiquidGlassStyle.pill(colorScheme);
final blur = unit * 0.4;
canvas
..save()
..clipPath(
Path()
..fillType = PathFillType.evenOdd
..addRect(bar.inflate(unit * 3))
..addPath(path, Offset.zero),
);
AppShape.full.paintInterior(
canvas,
bar.shift(shadow.offset),
shadow.toPaint(),
);
canvas
..restore()
..save()
..clipPath(path)
..saveLayer(
bar,
Paint()..imageFilter = ImageFilter.blur(sigmaX: blur, sigmaY: blur),
);
_paintPages(canvas, geometry);
final rimWidth = unit * 0.15;
canvas
..restore()
..drawPath(path, Paint()..color = style.tint)
..drawPath(
AppShape.full.getOuterPath(bar.deflate(rimWidth / 2)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = rimWidth
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: style.rim),
Colors.white.withValues(alpha: style.rim * 0.15),
Colors.white.withValues(alpha: style.rim * 0.5),
],
).createShader(bar),
)
..restore();
}
void _paintPage(
Canvas canvas,
_MiniGeometry geometry,
int index,
double offset, {
double opacity = 1,
}) {
final alpha = Color.getAlphaFromOpacity(opacity);
if (alpha == 0) {
return;
}
final size = geometry.size;
final margin = geometry.margin;
final content = Rect.fromLTRB(
offset + margin,
margin,
offset + size.width - margin,
size.height - geometry.pageBottom,
);
if (alpha < 255) {
canvas.saveLayer(null, Paint()..color = Color.fromARGB(alpha, 0, 0, 0));
} else {
canvas.save();
}
canvas.clipRect(content, doAntiAlias: false);
_PageSketch(
canvas,
colorScheme,
geometry.unit,
content,
).paint(_pageOf(index));
canvas.restore();
}
void _paintDestinations(Canvas canvas, Rect bar, double unit) {
final cell = bar.width / MiniScreen.destinationCount;
final dark = colorScheme.brightness == Brightness.dark;
final indicator = _glass
? colorScheme.onSurface.withValues(alpha: dark ? 0.14 : 0.08)
: SurfaceLevel.floatingOf(colorScheme).next.colorOf(colorScheme);
for (var i = 0; i < MiniScreen.destinationCount; i++) {
final center = Offset(bar.left + cell * i + cell / 2, bar.center.dy);
final icon = center.translate(0, -unit * 0.4);
final isSelected = i == selected;
if (isSelected) {
_fill(
canvas,
floatingBar
? Rect.fromCenter(
center: center,
width: cell - unit * 0.5,
height: bar.height - unit * 0.5,
)
: Rect.fromCenter(
center: icon,
width: unit * 2.8,
height: unit * 1.5,
),
indicator,
AppShape.full,
);
}
final ink = isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant;
_fill(
canvas,
Rect.fromCircle(center: icon, radius: unit * 0.45),
ink,
AppShape.circle,
);
_fill(
canvas,
Rect.fromCenter(
center: center.translate(0, unit * 0.8),
width: unit * 1.7,
height: unit * 0.35,
),
isSelected ? ink : ink.withValues(alpha: 0.6),
AppShape.full,
);
}
}
@override
bool shouldRepaint(_MiniScreenPainter oldDelegate) {
return oldDelegate.colorScheme != colorScheme ||
oldDelegate.floatingBar != floatingBar ||
oldDelegate.liquidGlass != liquidGlass ||
oldDelegate.selected != selected ||
oldDelegate.previous != previous ||
oldDelegate.progress != progress ||
oldDelegate.tabAnimation != tabAnimation;
}
}
/// One page of a [MiniScreen] after the app's own, laid out in units of a
/// twentieth of the screen's width from the top left of [content].
class _PageSketch {
_PageSketch(this.canvas, this.colorScheme, this.unit, this.content)
: width = content.width / unit,
card = SurfaceLevel.card.colorOf(colorScheme),
inset = SurfaceLevel.inset.colorOf(colorScheme),
text = colorScheme.onSurface.withValues(alpha: 0.72),
line = colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
faint = colorScheme.onSurfaceVariant.withValues(alpha: 0.16);
static const double _gap = 0.6;
final Canvas canvas;
final ColorScheme colorScheme;
final double unit;
final Rect content;
final double width;
final Color card;
final Color inset;
final Color text;
final Color line;
final Color faint;
void paint(_MiniPage page) {
switch (page) {
case _MiniPage.dashboard:
_dashboard(_header(actions: 1));
case _MiniPage.proxies:
_proxies(_header(actions: 2));
case _MiniPage.profiles:
_profiles(_header(actions: 2));
case _MiniPage.tools:
_tools(_header(actions: 0));
}
}
Rect _rect(double x, double y, double w, double h) => Rect.fromLTWH(
content.left + x * unit,
content.top + y * unit,
w * unit,
h * unit,
);
ShapeBorder _shape(double extent) =>
AppShape.all(AppCorner.fit(unit * extent));
void _block(Rect rect, Color color, {double extent = 5}) {
_MiniScreenPainter._fill(canvas, rect, color, _shape(extent));
}
void _pill(double x, double y, double w, double h, Color color) {
_MiniScreenPainter._fill(canvas, _rect(x, y, w, h), color, AppShape.full);
}
void _dot(double x, double y, double radius, Color color) {
_MiniScreenPainter._fill(
canvas,
_rect(x - radius, y - radius, radius * 2, radius * 2),
color,
AppShape.circle,
);
}
/// The title and the bar's round buttons; returns where the body starts.
double _header({required int actions}) {
_pill(0, 0.45, 7, 1.1, text);
for (var i = 0; i < actions; i++) {
final x = width - 1 - i * 2.4;
_dot(x, 1, 1, card);
_dot(x, 1, 0.3, line);
}
return 2.8;
}
void _dashboard(double top) {
var y = top;
final status = _rect(0, y, width, 6.2);
final buttonX = width - 3;
final buttonY = y + 3.1;
_block(status, card);
canvas
..save()
..clipPath(_shape(5).getOuterPath(status))
..drawCircle(
_rect(buttonX, buttonY, 0, 0).topLeft,
unit * 4.6,
Paint()..color = colorScheme.primary.withValues(alpha: 0.1),
)
..restore();
_dot(buttonX, buttonY, 1.8, colorScheme.primary);
for (final dx in [-0.675, 0.325]) {
_pill(buttonX + dx, buttonY - 0.6, 0.35, 1.2, colorScheme.onPrimary);
}
_pill(1, y + 0.9, 3, 0.7, line);
_dot(1.9, y + 3, 0.9, inset);
_pill(3.5, y + 2.3, 5, 0.9, text);
_pill(3.5, y + 3.5, 2.4, 0.7, inset);
_pill(1, y + 4.8, 2.4, 0.7, line);
_pill(4.6, y + 4.8, 3.6, 0.7, line);
y += 6.2 + _gap;
_block(_rect(0, y, width, 5.8), card);
_pill(1, y + 0.9, 4.5, 0.7, line);
final tileWidth = (width - 2.6) / 2;
for (var i = 0; i < 2; i++) {
final x = 1 + i * (tileWidth + 0.6);
final tile = _rect(x, y + 2.1, tileWidth, 2.9);
_block(tile, inset, extent: 2.9);
_pill(x + 0.6, y + 2.65, 2.4, 0.7, line);
_speedGraph(
tile,
phase: i * 1.7,
color: i == 0
? colorScheme.primary.withValues(alpha: 0.5)
: colorScheme.primary,
);
}
y += 5.8 + _gap;
_block(_rect(0, y, width, 4.4), card);
_pill(1, y + 0.9, 4, 0.7, line);
final segment = (width - 2.6) / 3;
for (var i = 0; i < 3; i++) {
final x = 1 + i * (segment + 0.3);
_pill(x, y + 2.1, segment, 1.6, i == 0 ? colorScheme.primary : inset);
_dot(
x + segment / 2,
y + 2.9,
0.3,
i == 0 ? colorScheme.onPrimary : line,
);
}
y += 4.4 + _gap;
_block(_rect(0, y, width, 3), card);
_dot(1.9, y + 1.5, 0.9, colorScheme.primary);
_pill(3.4, y + 1.1, 2.4, 0.8, line);
_pill(width - 3.6, y + 0.8, 2.6, 1.4, colorScheme.primary);
_dot(width - 1.7, y + 1.5, 0.55, colorScheme.onPrimary);
y += 3 + _gap;
for (var i = 0; i < 3; i++) {
_block(_rect(0, y, width, 4.5), card);
_pill(1, y + 0.9, 3.5 + i % 2, 0.7, line);
y += 4.5 + _gap;
}
}
void _speedGraph(Rect tile, {required double phase, required Color color}) {
const steps = 16;
final path = Path();
for (var i = 0; i <= steps; i++) {
final x = tile.left + tile.width * i / steps;
final y =
tile.top +
tile.height * 0.68 +
math.sin(i * 0.9 + phase) * unit * 0.35;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
final area = Path.from(path)
..lineTo(tile.right, tile.bottom)
..lineTo(tile.left, tile.bottom)
..close();
canvas
..save()
..clipPath(_shape(2.9).getOuterPath(tile))
..drawPath(area, Paint()..color = color.withValues(alpha: color.a * 0.2))
..drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = unit * 0.15
..color = color,
)
..restore();
}
void _proxies(double top) {
var y = top;
for (var i = 0; i < 9; i++) {
_block(_rect(0, y, width, 2.6), card);
_dot(1.5, y + 1.3, 0.8, inset);
_pill(3, y + 0.6, 3 + i % 3, 0.7, line);
_pill(3, y + 1.55, 4, 0.5, faint);
_pill(width - 4.4, y + 1.05, 1.8, 0.5, faint);
_dot(width - 1.3, y + 1.3, 0.55, inset);
y += 2.6 + _gap;
}
}
void _profiles(double top) {
var y = top;
for (var i = 0; i < 3; i++) {
_block(_rect(0, y, width, 2.8), card);
_pill(1, y + 0.7, 3.4 - i * 0.6, 0.75, line);
_pill(
4.8 - i * 0.6,
y + 0.72,
2.6,
0.7,
colorScheme.primary.withValues(alpha: 0.16),
);
_pill(1, y + 1.7, 3, 0.5, faint);
_pill(width - 4.1, y + 1.15, 1.6, 0.5, faint);
if (i == 0) {
_dot(width - 1.4, y + 1.4, 0.6, colorScheme.primary);
}
y += 2.8 + _gap;
}
}
void _tools(double top) {
const rowHeight = 2.4;
var y = top;
for (final rows in [4, 7]) {
_pill(0.8, y + 0.2, 1.6 + rows * 0.15, 0.5, faint);
y += 1.3;
_block(_rect(0, y, width, rows * rowHeight), card);
for (var i = 0; i < rows; i++) {
final row = y + i * rowHeight;
_dot(1.3, row + rowHeight / 2, 0.45, line);
_pill(2.6, row + 0.85, 4 + (i * 3 + rows) % 4, 0.7, line);
if (i < rows - 1) {
canvas.drawRect(
_rect(2.6, row + rowHeight - 0.03, width - 3.4, 0.06),
Paint()..color = faint,
);
}
}
y += rows * rowHeight + _gap * 2;
}
}
}