Add linux deb dependencies Add backup recovery strategy select Support custom text scaling Optimize the display of different text scale Optimize windows setup experience Optimize startTun performance Optimize android tv experience Optimize default option Optimize computed text size Optimize hyperOS freeform window Add developer mode Update core Optimize more details
730 lines
21 KiB
Dart
730 lines
21 KiB
Dart
// ignore_for_file: deprecated_member_use
|
|
|
|
import 'dart:math';
|
|
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/models/selector.dart';
|
|
import 'package:fl_clash/providers/config.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:fl_clash/widgets/widgets.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:intl/intl.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;
|
|
|
|
const FontFamilyItem({
|
|
required this.fontFamily,
|
|
required this.label,
|
|
});
|
|
}
|
|
|
|
class ThemeFragment extends StatelessWidget {
|
|
const ThemeFragment({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
spacing: 24,
|
|
children: [
|
|
_ThemeModeItem(),
|
|
_PrimaryColorItem(),
|
|
_PrueBlackItem(),
|
|
_TextScaleFactorItem(),
|
|
const SizedBox(
|
|
height: 64,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ItemCard extends StatelessWidget {
|
|
final Widget child;
|
|
final Info info;
|
|
final List<Widget> actions;
|
|
|
|
const ItemCard({
|
|
super.key,
|
|
required this.info,
|
|
required this.child,
|
|
this.actions = const [],
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Wrap(
|
|
runSpacing: 16,
|
|
children: [
|
|
InfoHeader(
|
|
info: info,
|
|
actions: actions,
|
|
),
|
|
child,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemeModeItem extends ConsumerWidget {
|
|
const _ThemeModeItem();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final themeMode =
|
|
ref.watch(themeSettingProvider.select((state) => state.themeMode));
|
|
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,
|
|
),
|
|
];
|
|
return 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).updateState(
|
|
(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,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrimaryColorItem extends ConsumerStatefulWidget {
|
|
const _PrimaryColorItem();
|
|
|
|
@override
|
|
ConsumerState<_PrimaryColorItem> createState() => _PrimaryColorItemState();
|
|
}
|
|
|
|
class _PrimaryColorItemState extends ConsumerState<_PrimaryColorItem> {
|
|
int? _removablePrimaryColor;
|
|
|
|
int _calcColumns(double maxWidth) {
|
|
return max((maxWidth / 96).ceil(), 3);
|
|
}
|
|
|
|
_handleReset() async {
|
|
final res = await globalState.showMessage(
|
|
message: TextSpan(
|
|
text: appLocalizations.resetTip,
|
|
),
|
|
);
|
|
if (res != true) {
|
|
return;
|
|
}
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) {
|
|
return state.copyWith(
|
|
primaryColors: defaultPrimaryColors,
|
|
primaryColor: defaultPrimaryColor,
|
|
schemeVariant: DynamicSchemeVariant.tonalSpot,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
_handleDel() async {
|
|
if (_removablePrimaryColor == null) {
|
|
return;
|
|
}
|
|
final res = await globalState.showMessage(
|
|
message: TextSpan(text: appLocalizations.deleteColorTip));
|
|
if (res != true) {
|
|
return;
|
|
}
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) {
|
|
final newPrimaryColors = List<int>.from(state.primaryColors)
|
|
..remove(_removablePrimaryColor);
|
|
int? newPrimaryColor = state.primaryColor;
|
|
if (state.primaryColor == _removablePrimaryColor) {
|
|
if (newPrimaryColors.contains(defaultPrimaryColor)) {
|
|
newPrimaryColor = defaultPrimaryColor;
|
|
} else {
|
|
newPrimaryColor = null;
|
|
}
|
|
}
|
|
return state.copyWith(
|
|
primaryColors: newPrimaryColors,
|
|
primaryColor: newPrimaryColor,
|
|
);
|
|
},
|
|
);
|
|
setState(() {
|
|
_removablePrimaryColor = null;
|
|
});
|
|
}
|
|
|
|
_handleAdd() async {
|
|
final res = await globalState.showCommonDialog<int>(
|
|
child: _PaletteDialog(),
|
|
);
|
|
if (res == null) {
|
|
return;
|
|
}
|
|
final isExists = ref.read(
|
|
themeSettingProvider.select((state) => state.primaryColors.contains(res)),
|
|
);
|
|
if (isExists && mounted) {
|
|
context.showNotifier(appLocalizations.colorExists);
|
|
return;
|
|
}
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) {
|
|
return state.copyWith(
|
|
primaryColors: List.from(
|
|
state.primaryColors,
|
|
)..add(res),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
_handleChangeSchemeVariant() async {
|
|
final schemeVariant = ref.read(
|
|
themeSettingProvider.select(
|
|
(state) => state.schemeVariant,
|
|
),
|
|
);
|
|
final value = await globalState.showCommonDialog<DynamicSchemeVariant>(
|
|
child: OptionsDialog<DynamicSchemeVariant>(
|
|
title: appLocalizations.colorSchemes,
|
|
options: DynamicSchemeVariant.values,
|
|
textBuilder: (item) => Intl.message("${item.name}Scheme"),
|
|
value: schemeVariant,
|
|
),
|
|
);
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) {
|
|
return state.copyWith(
|
|
schemeVariant: value,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final vm4 = ref.watch(
|
|
themeSettingProvider.select(
|
|
(state) => VM4(
|
|
a: state.primaryColor,
|
|
b: state.primaryColors,
|
|
c: state.schemeVariant,
|
|
d: state.primaryColor == defaultPrimaryColor &&
|
|
intListEquality.equals(state.primaryColors, defaultPrimaryColors),
|
|
),
|
|
),
|
|
);
|
|
final primaryColor = vm4.a;
|
|
final primaryColors = [null, ...vm4.b];
|
|
final schemeVariant = vm4.c;
|
|
final isEquals = vm4.d;
|
|
|
|
return CommonPopScope(
|
|
onPop: () {
|
|
if (_removablePrimaryColor != null) {
|
|
setState(() {
|
|
_removablePrimaryColor = null;
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
child: ItemCard(
|
|
info: Info(
|
|
label: appLocalizations.themeColor,
|
|
iconData: Icons.palette,
|
|
),
|
|
actions: genActions(
|
|
[
|
|
if (_removablePrimaryColor == null)
|
|
FilledButton(
|
|
style: ButtonStyle(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
onPressed: _handleChangeSchemeVariant,
|
|
child: Text(Intl.message("${schemeVariant.name}Scheme")),
|
|
),
|
|
if (_removablePrimaryColor != null)
|
|
FilledButton(
|
|
style: ButtonStyle(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_removablePrimaryColor = null;
|
|
});
|
|
},
|
|
child: Text(appLocalizations.cancel),
|
|
),
|
|
if (_removablePrimaryColor == null && !isEquals)
|
|
IconButton.filledTonal(
|
|
iconSize: 20,
|
|
padding: EdgeInsets.all(4),
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: _handleReset,
|
|
icon: Icon(Icons.replay),
|
|
)
|
|
],
|
|
space: 8,
|
|
),
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (_, constraints) {
|
|
final columns = _calcColumns(constraints.maxWidth);
|
|
final itemWidth =
|
|
(constraints.maxWidth - (columns - 1) * 16) / columns;
|
|
return Wrap(
|
|
spacing: 16,
|
|
runSpacing: 16,
|
|
children: [
|
|
for (final color in primaryColors)
|
|
Container(
|
|
clipBehavior: Clip.none,
|
|
width: itemWidth,
|
|
height: itemWidth,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
EffectGestureDetector(
|
|
child: ColorSchemeBox(
|
|
isSelected: color == primaryColor,
|
|
primaryColor: color != null ? Color(color) : null,
|
|
onPressed: () {
|
|
setState(() {
|
|
_removablePrimaryColor = null;
|
|
});
|
|
ref
|
|
.read(themeSettingProvider.notifier)
|
|
.updateState(
|
|
(state) => state.copyWith(
|
|
primaryColor: color,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
onLongPress: () {
|
|
setState(() {
|
|
_removablePrimaryColor = color;
|
|
});
|
|
},
|
|
),
|
|
if (_removablePrimaryColor != null &&
|
|
_removablePrimaryColor == color)
|
|
Container(
|
|
color: Colors.white.opacity0,
|
|
padding: EdgeInsets.all(8),
|
|
child: IconButton.filledTonal(
|
|
onPressed: _handleDel,
|
|
padding: EdgeInsets.all(12),
|
|
iconSize: 30,
|
|
icon: Icon(
|
|
color: context.colorScheme.primary,
|
|
Icons.delete,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_removablePrimaryColor == null)
|
|
Container(
|
|
width: itemWidth,
|
|
height: itemWidth,
|
|
padding: EdgeInsets.all(
|
|
4,
|
|
),
|
|
child: IconButton.filledTonal(
|
|
onPressed: _handleAdd,
|
|
iconSize: 32,
|
|
icon: Icon(
|
|
color: context.colorScheme.primary,
|
|
Icons.add,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrueBlackItem extends ConsumerWidget {
|
|
const _PrueBlackItem();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final prueBlack = ref.watch(
|
|
themeSettingProvider.select(
|
|
(state) => state.pureBlack,
|
|
),
|
|
);
|
|
return ListItem.switchItem(
|
|
leading: Icon(
|
|
Icons.contrast,
|
|
),
|
|
horizontalTitleGap: 12,
|
|
title: Text(
|
|
appLocalizations.pureBlackMode,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: context.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
delegate: SwitchDelegate(
|
|
value: prueBlack,
|
|
onChanged: (value) {
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith(
|
|
pureBlack: value,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextScaleFactorItem extends ConsumerWidget {
|
|
const _TextScaleFactorItem();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final textScale = ref.watch(
|
|
themeSettingProvider.select(
|
|
(state) => state.textScale,
|
|
),
|
|
);
|
|
final String process = "${((textScale.scale * 100) as double).round()}%";
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 8),
|
|
child: ListItem.switchItem(
|
|
leading: Icon(
|
|
Icons.text_fields,
|
|
),
|
|
horizontalTitleGap: 12,
|
|
title: Text(
|
|
appLocalizations.textScale,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: context.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
delegate: SwitchDelegate(
|
|
value: textScale.enable,
|
|
onChanged: (value) {
|
|
ref.read(themeSettingProvider.notifier).updateState(
|
|
(state) => state.copyWith.textScale(
|
|
enable: value,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
mainAxisSize: MainAxisSize.max,
|
|
spacing: 32,
|
|
children: [
|
|
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).updateState(
|
|
(state) => state.copyWith.textScale(
|
|
scale: value,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.only(right: 4),
|
|
child: Text(
|
|
process,
|
|
style: context.textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PaletteDialog extends StatefulWidget {
|
|
const _PaletteDialog();
|
|
|
|
@override
|
|
State<_PaletteDialog> createState() => _PaletteDialogState();
|
|
}
|
|
|
|
class _PaletteDialogState extends State<_PaletteDialog> {
|
|
final _controller = ValueNotifier<ui.Color>(Colors.transparent);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CommonDialog(
|
|
title: appLocalizations.palette,
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: Text(appLocalizations.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(_controller.value.toARGB32());
|
|
},
|
|
child: Text(appLocalizations.confirm),
|
|
),
|
|
],
|
|
child: Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 8,
|
|
),
|
|
SizedBox(
|
|
width: 250,
|
|
height: 250,
|
|
child: Palette(
|
|
controller: _controller,
|
|
),
|
|
),
|
|
SizedBox(
|
|
height: 24,
|
|
),
|
|
ValueListenableBuilder(
|
|
valueListenable: _controller,
|
|
builder: (_, color, __) {
|
|
return PrimaryColorBox(
|
|
primaryColor: color,
|
|
child: FilledButton(
|
|
onPressed: () {},
|
|
child: Text(
|
|
_controller.value.hex,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SliderDefaultsM3 extends SliderThemeData {
|
|
_SliderDefaultsM3(this.context) : super(trackHeight: 16.0);
|
|
|
|
final BuildContext context;
|
|
late final ColorScheme _colors = Theme.of(context).colorScheme;
|
|
|
|
@override
|
|
Color? get activeTrackColor => _colors.primary;
|
|
|
|
@override
|
|
Color? get inactiveTrackColor => _colors.secondaryContainer;
|
|
|
|
@override
|
|
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54);
|
|
|
|
@override
|
|
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38);
|
|
|
|
@override
|
|
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12);
|
|
|
|
@override
|
|
Color? get disabledSecondaryActiveTrackColor =>
|
|
_colors.onSurface.withOpacity(0.38);
|
|
|
|
@override
|
|
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(1.0);
|
|
|
|
@override
|
|
Color? get inactiveTickMarkColor =>
|
|
_colors.onSecondaryContainer.withOpacity(1.0);
|
|
|
|
@override
|
|
Color? get disabledActiveTickMarkColor => _colors.onInverseSurface;
|
|
|
|
@override
|
|
Color? get disabledInactiveTickMarkColor => _colors.onSurface;
|
|
|
|
@override
|
|
Color? get thumbColor => _colors.primary;
|
|
|
|
@override
|
|
Color? get disabledThumbColor => _colors.onSurface.withOpacity(0.38);
|
|
|
|
@override
|
|
Color? get overlayColor =>
|
|
WidgetStateColor.resolveWith((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.dragged)) {
|
|
return _colors.primary.withOpacity(0.1);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return _colors.primary.withOpacity(0.08);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return _colors.primary.withOpacity(0.1);
|
|
}
|
|
|
|
return Colors.transparent;
|
|
});
|
|
|
|
@override
|
|
TextStyle? get valueIndicatorTextStyle =>
|
|
Theme.of(context).textTheme.labelLarge!.copyWith(
|
|
color: _colors.onInverseSurface,
|
|
);
|
|
|
|
@override
|
|
Color? get valueIndicatorColor => _colors.inverseSurface;
|
|
|
|
@override
|
|
SliderComponentShape? get valueIndicatorShape =>
|
|
const RoundedRectSliderValueIndicatorShape();
|
|
|
|
@override
|
|
SliderComponentShape? get thumbShape => const HandleThumbShape();
|
|
|
|
@override
|
|
SliderTrackShape? get trackShape => const GappedSliderTrackShape();
|
|
|
|
@override
|
|
SliderComponentShape? get overlayShape => const RoundSliderOverlayShape();
|
|
|
|
@override
|
|
SliderTickMarkShape? get tickMarkShape =>
|
|
const RoundSliderTickMarkShape(tickMarkRadius: 4.0 / 2);
|
|
|
|
@override
|
|
WidgetStateProperty<Size?>? get thumbSize {
|
|
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
|
|
if (states.contains(WidgetState.disabled)) {
|
|
return const Size(4.0, 44.0);
|
|
}
|
|
if (states.contains(WidgetState.hovered)) {
|
|
return const Size(4.0, 44.0);
|
|
}
|
|
if (states.contains(WidgetState.focused)) {
|
|
return const Size(2.0, 44.0);
|
|
}
|
|
if (states.contains(WidgetState.pressed)) {
|
|
return const Size(2.0, 44.0);
|
|
}
|
|
return const Size(4.0, 44.0);
|
|
});
|
|
}
|
|
|
|
@override
|
|
double? get trackGap => 6.0;
|
|
}
|