Files
PiliPlus/lib/pages/setting/models/style_settings.dart
dom d27f5f315c drop
Signed-off-by: dom <githubaccount56556@proton.me>
2026-05-01 14:27:27 +08:00

798 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/color_palette.dart';
import 'package:PiliPlus/common/widgets/scale_app.dart';
import 'package:PiliPlus/common/widgets/stateful_builder.dart';
import 'package:PiliPlus/models/common/bar_hide_type.dart';
import 'package:PiliPlus/models/common/dynamic/dynamic_badge_mode.dart';
import 'package:PiliPlus/models/common/dynamic/up_panel_position.dart';
import 'package:PiliPlus/models/common/home_tab_type.dart';
import 'package:PiliPlus/models/common/msg/msg_unread_type.dart';
import 'package:PiliPlus/models/common/nav_bar_config.dart';
import 'package:PiliPlus/models/common/theme/theme_color_type.dart';
import 'package:PiliPlus/models/common/theme/theme_type.dart';
import 'package:PiliPlus/pages/main/controller.dart';
import 'package:PiliPlus/pages/mine/controller.dart';
import 'package:PiliPlus/pages/setting/models/model.dart';
import 'package:PiliPlus/pages/setting/widgets/dual_slider_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/multi_select_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart';
import 'package:PiliPlus/pages/setting/widgets/slider_dialog.dart';
import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart';
import 'package:PiliPlus/utils/extension/file_ext.dart';
import 'package:PiliPlus/utils/extension/get_ext.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/theme_ext.dart';
import 'package:PiliPlus/utils/global_data.dart';
import 'package:PiliPlus/utils/path_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/storage_key.dart';
import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:PiliPlus/utils/theme_utils.dart';
import 'package:flutter/material.dart' hide StatefulBuilder;
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:path/path.dart' as path;
List<SettingsModel> get styleSettings => [
if (PlatformUtils.isDesktop) ...[
const SwitchModel(
title: '显示窗口标题栏',
leading: Icon(Icons.window),
setKey: SettingBoxKey.showWindowTitleBar,
defaultVal: true,
needReboot: true,
),
const SwitchModel(
title: '显示托盘图标',
leading: Icon(Icons.donut_large_rounded),
setKey: SettingBoxKey.showTrayIcon,
defaultVal: true,
needReboot: true,
),
],
if (Platform.isLinux) _useSSDModel(),
SwitchModel(
title: '横屏适配',
subtitle: '启用横屏布局与逻辑,平板、折叠屏等可开启;建议全屏方向设为【不改变当前方向】',
leading: const Icon(Icons.phonelink_outlined),
setKey: SettingBoxKey.horizontalScreen,
defaultVal: Pref.horizontalScreen,
onChanged: (value) {
if (value) {
fullMode();
} else {
portraitUpMode();
}
},
),
NormalModel(
title: '界面缩放',
getSubtitle: () => '当前缩放比例:${Pref.uiScale.toStringAsFixed(2)}',
leading: const Icon(Icons.zoom_in_outlined),
onTap: _showUiScaleDialog,
),
NormalModel(
title: '页面过渡动画',
leading: const Icon(Icons.animation),
getSubtitle: () => '当前:${Pref.pageTransition.name}',
onTap: _showTransitionDialog,
),
const SwitchModel(
title: '悬浮底栏',
leading: Icon(MdiIcons.soundbar),
setKey: SettingBoxKey.floatingNavBar,
defaultVal: false,
needReboot: true,
),
NormalModel(
leading: const Icon(Icons.calendar_view_week_outlined),
title: '列表宽度dp限制',
getSubtitle: () =>
'当前: 主页${Pref.recommendCardWidth.toInt()}dp 其他${Pref.smallCardWidth.toInt()}dp屏幕宽度:${MediaQuery.widthOf(Get.context!).toPrecision(2)}dp。宽度越小列数越多。',
onTap: _showCardWidthDialog,
),
const SwitchModel(
title: '播放页移除安全边距',
leading: Icon(Icons.fit_screen_outlined),
setKey: SettingBoxKey.removeSafeArea,
defaultVal: false,
),
const SwitchModel(
title: '视频播放页使用深色主题',
leading: Icon(Icons.dark_mode_outlined),
setKey: SettingBoxKey.darkVideoPage,
defaultVal: false,
),
SwitchModel(
title: '动态页启用瀑布流',
subtitle: '关闭会显示为单列',
leading: const Icon(Icons.view_array_outlined),
setKey: SettingBoxKey.dynamicsWaterfallFlow,
defaultVal: Pref.horizontalScreen,
needReboot: true,
),
NormalModel(
title: '动态页UP主显示位置',
leading: const Icon(Icons.person_outlined),
getSubtitle: () => '当前:${Pref.upPanelPosition.label}',
onTap: _showUpPosDialog,
),
const SwitchModel(
title: '动态页显示所有已关注UP主',
leading: Icon(Icons.people_alt_outlined),
setKey: SettingBoxKey.dynamicsShowAllFollowedUp,
defaultVal: false,
needReboot: true,
),
const SwitchModel(
title: '动态页展开正在直播UP列表',
leading: Icon(Icons.live_tv),
setKey: SettingBoxKey.expandDynLivePanel,
defaultVal: false,
needReboot: true,
),
NormalModel(
title: '动态未读标记',
leading: const Icon(Icons.motion_photos_on_outlined),
getSubtitle: () => '当前标记样式:${Pref.dynamicBadgeType.desc}',
onTap: _showDynBadgeDialog,
),
NormalModel(
title: '消息未读标记',
leading: const Icon(MdiIcons.bellBadgeOutline),
getSubtitle: () => '当前标记样式:${Pref.msgBadgeMode.desc}',
onTap: _showMsgBadgeDialog,
),
NormalModel(
onTap: _showMsgUnReadDialog,
title: '消息未读类型',
leading: const Icon(MdiIcons.bellCogOutline),
getSubtitle: () =>
'当前消息类型:${Pref.msgUnReadTypeV2.map((item) => item.title).join('')}',
),
NormalModel(
onTap: _showBarHideTypeDialog,
title: '顶/底栏收起类型',
leading: const Icon(MdiIcons.arrowExpandVertical),
getSubtitle: () => '当前:${Pref.barHideType.label}',
),
SwitchModel(
title: '首页顶栏收起',
subtitle: '首页列表滑动时,收起顶栏',
leading: const Icon(Icons.vertical_align_top_outlined),
setKey: SettingBoxKey.hideTopBar,
defaultVal: PlatformUtils.isMobile,
needReboot: true,
),
SwitchModel(
title: '首页底栏收起',
subtitle: '首页列表滑动时,收起底栏',
leading: const Icon(Icons.vertical_align_bottom_outlined),
setKey: SettingBoxKey.hideBottomBar,
defaultVal: PlatformUtils.isMobile,
needReboot: true,
),
NormalModel(
onTap: (context, setState) => _showQualityDialog(
context: context,
title: '图片质量',
initValue: Pref.picQuality,
onChanged: (picQuality) async {
GlobalData().imgQuality = picQuality;
await GStorage.setting.put(SettingBoxKey.defaultPicQa, picQuality);
setState();
},
),
title: '图片质量',
subtitle: '选择合适的图片清晰度上限100%',
leading: const Icon(Icons.image_outlined),
getTrailing: (theme) => Text(
'${Pref.picQuality}%',
style: theme.textTheme.titleSmall,
),
),
NormalModel(
onTap: (context, setState) => _showQualityDialog(
context: context,
title: '查看大图质量',
initValue: Pref.previewQ,
onChanged: (picQuality) async {
await GStorage.setting.put(SettingBoxKey.previewQuality, picQuality);
setState();
},
),
title: '查看大图质量',
subtitle: '选择合适的图片清晰度上限100%',
leading: const Icon(Icons.image_outlined),
getTrailing: (theme) => Text(
'${Pref.previewQ}%',
style: theme.textTheme.titleSmall,
),
),
NormalModel(
onTap: _showThemeTypeDialog,
leading: const Icon(Icons.flashlight_on_outlined),
title: '主题模式',
getSubtitle: () => '当前模式:${Pref.themeType.desc}',
),
SwitchModel(
leading: const Icon(Icons.invert_colors),
title: '纯黑主题',
setKey: SettingBoxKey.isPureBlackTheme,
defaultVal: false,
onChanged: (value) {
if (ThemeUtils.isDarkMode || Pref.darkVideoPage) {
Get.updateMyAppTheme();
}
},
),
NormalModel(
onTap: (context, setState) => Get.toNamed('/colorSetting'),
leading: const Icon(Icons.color_lens_outlined),
title: '应用主题',
getSubtitle: () => '当前主题:${Pref.dynamicColor ? '动态取色' : '指定颜色'}',
getTrailing: (theme) => Pref.dynamicColor
? Icon(Icons.color_lens_rounded, color: theme.colorScheme.primary)
: SizedBox.square(
dimension: 20,
child: ColorPalette(
colorScheme: colorThemeTypes[Pref.customColor].color
.asColorSchemeSeed(Pref.schemeVariant, theme.brightness),
selected: false,
showBgColor: false,
),
),
),
NormalModel(
leading: const Icon(Icons.home_outlined),
title: '默认启动页',
getSubtitle: () => '当前启动页:${Pref.defaultHomePage.label}',
onTap: _showDefHomeDialog,
),
const NormalModel(
title: '滑动动画弹簧参数',
leading: Icon(Icons.chrome_reader_mode_outlined),
onTap: _showSpringDialog,
),
NormalModel(
onTap: (context, setState) async {
final res = await Get.toNamed('/fontSizeSetting');
if (res != null) {
setState();
}
},
title: '字体大小',
leading: const Icon(Icons.format_size_outlined),
getSubtitle: () {
final scale = Pref.defaultTextScale;
return scale == 1.0 ? '默认' : scale.toString();
},
),
NormalModel(
onTap: (context, setState) => Get.toNamed(
'/barSetting',
arguments: {
'key': SettingBoxKey.tabBarSort,
'defaultBars': HomeTabType.values,
'title': '首页标签页',
},
),
title: '首页标签页',
subtitle: '删除或调换首页标签页',
leading: const Icon(Icons.toc_outlined),
),
NormalModel(
onTap: (context, setState) => Get.toNamed(
'/barSetting',
arguments: {
'key': SettingBoxKey.navBarSort,
'defaultBars': NavigationBarType.values,
'title': 'Navbar',
},
),
title: 'Navbar编辑',
subtitle: '删除或调换Navbar',
leading: const Icon(Icons.toc_outlined),
),
SwitchModel(
title: '返回时直接退出',
subtitle: '开启后在主页任意tab按返回键都直接退出关闭则先回到Navbar的第一个tab',
leading: const Icon(Icons.exit_to_app_outlined),
setKey: SettingBoxKey.directExitOnBack,
defaultVal: false,
onChanged: (value) => Get.find<MainController>().directExitOnBack = value,
),
];
void _showQualityDialog({
required BuildContext context,
required String title,
required int initValue,
required ValueChanged<int> onChanged,
}) {
showDialog<double>(
context: context,
builder: (context) => SliderDialog(
value: initValue.toDouble(),
title: title,
min: 10,
max: 100,
divisions: 9,
suffix: '%',
precise: 0,
),
).then((result) {
if (result != null) {
SmartDialog.showToast('设置成功');
onChanged(result.toInt());
}
});
}
void _showUiScaleDialog(
BuildContext context,
VoidCallback setState,
) {
const minUiScale = 0.5;
const maxUiScale = 2.0;
double uiScale = Pref.uiScale;
final textController = TextEditingController(
text: uiScale.toStringAsFixed(2),
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('界面缩放'),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 12),
content: StatefulBuilder(
onDispose: textController.dispose,
builder: (context, setDialogState) => Column(
spacing: 20,
mainAxisSize: MainAxisSize.min,
children: [
Slider(
padding: .zero,
value: uiScale,
min: minUiScale,
max: maxUiScale,
secondaryTrackValue: 1.0,
divisions: ((maxUiScale - minUiScale) * 20).toInt(),
label: textController.text,
onChanged: (value) => setDialogState(() {
uiScale = value.toPrecision(2);
textController.text = uiScale.toStringAsFixed(2);
}),
),
TextFormField(
controller: textController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
LengthLimitingTextInputFormatter(4),
FilteringTextInputFormatter.allow(RegExp(r'[\d.]+')),
],
decoration: const InputDecoration(
labelText: '缩放比例',
hintText: '0.50 - 2.00',
border: OutlineInputBorder(),
),
onChanged: (value) {
final parsed = double.tryParse(value);
if (parsed != null &&
parsed >= minUiScale &&
parsed <= maxUiScale) {
setDialogState(() {
uiScale = parsed;
});
}
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
GStorage.setting.delete(SettingBoxKey.uiScale).whenComplete(() {
setState();
Get.appUpdate();
ScaledWidgetsFlutterBinding.instance.scaleFactor = 1.0;
});
},
child: const Text('重置'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'取消',
style: TextStyle(color: ColorScheme.of(context).outline),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
GStorage.setting.put(SettingBoxKey.uiScale, uiScale).whenComplete(
() {
setState();
Get.appUpdate();
ScaledWidgetsFlutterBinding.instance.scaleFactor = uiScale;
},
);
},
child: const Text('确定'),
),
],
),
);
}
void _showSpringDialog(BuildContext context, _) {
final List<String> springDescription = Pref.springDescription
.map((i) => i.toString())
.toList(growable: false);
bool physicalMode = true;
void physical2Duration() {
final mass = double.parse(springDescription[0]);
final stiffness = double.parse(springDescription[1]);
final damping = double.parse(springDescription[2]);
final duration = math.sqrt(4 * math.pi * math.pi * mass / stiffness);
final dampingRatio = damping / (2.0 * math.sqrt(mass * stiffness));
final bounce = dampingRatio < 1.0
? 1.0 - dampingRatio
: 1.0 / dampingRatio - 1;
springDescription[0] = duration.toString();
springDescription[1] = bounce.toString();
}
/// from [SpringDescription.withDurationAndBounce] but with higher precision
void duration2Physical() {
final duration = double.parse(springDescription[0]);
final bounce = double.parse(springDescription[1]).clamp(-1.0, 1.0);
final stiffness = 4 * math.pi * math.pi / math.pow(duration, 2);
final dampingRatio = bounce > 0 ? 1.0 - bounce : 1.0 / (bounce + 1);
final damping = 2 * math.sqrt(stiffness) * dampingRatio;
springDescription[0] = '1';
springDescription[1] = stiffness.toString();
springDescription[2] = damping.toString();
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
mainAxisAlignment: .spaceBetween,
children: [
const Text('弹簧参数'),
TextButton(
style: TextButton.styleFrom(
visualDensity: .compact,
tapTargetSize: .shrinkWrap,
),
onPressed: () {
try {
if (physicalMode) {
physical2Duration();
} else {
duration2Physical();
}
physicalMode = !physicalMode;
(context as Element).markNeedsBuild();
} catch (e) {
SmartDialog.showToast(e.toString());
}
},
child: Text(physicalMode ? '滑动时间' : '物理参数'),
),
],
),
content: Column(
key: ValueKey(physicalMode),
mainAxisSize: .min,
children: List.generate(
physicalMode ? 3 : 2,
(index) => TextFormField(
autofocus: index == 0,
initialValue: springDescription[index],
keyboardType: .numberWithOptions(
signed: !physicalMode && index == 1,
decimal: true,
),
onChanged: (value) => springDescription[index] = value,
inputFormatters: [
!physicalMode && index == 1
? FilteringTextInputFormatter.allow(RegExp(r'[-\d\.]+'))
: FilteringTextInputFormatter.allow(RegExp(r'[\d\.]+')),
],
decoration: InputDecoration(
labelText: (physicalMode
? const ['mass', 'stiffness', 'damping']
: const ['duration', 'bounce'])[index],
suffixText: !physicalMode && index == 0 ? 's' : null,
),
),
),
),
actions: [
TextButton(
onPressed: () {
Get.back();
GStorage.setting.delete(SettingBoxKey.springDescription);
SmartDialog.showToast('重置成功,重启生效');
},
child: const Text('重置'),
),
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(color: ColorScheme.of(context).outline),
),
),
TextButton(
onPressed: () {
try {
if (!physicalMode) {
duration2Physical();
}
final res = springDescription.map(double.parse).toList();
Get.back();
GStorage.setting.put(SettingBoxKey.springDescription, res);
SmartDialog.showToast('设置成功,重启生效');
} catch (e) {
SmartDialog.showToast(e.toString());
}
},
child: const Text('确定'),
),
],
),
);
}
Future<void> _showTransitionDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<Transition>(
context: context,
builder: (context) => SelectDialog<Transition>(
title: '页面过渡动画',
value: Pref.pageTransition,
values: Transition.values.map((e) => (e, e.name)).toList(),
),
);
if (res != null) {
await GStorage.setting.put(SettingBoxKey.pageTransition, res.index);
SmartDialog.showToast('重启生效');
setState();
}
}
Future<void> _showCardWidthDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<(double, double)>(
context: context,
builder: (context) => DualSliderDialog(
title: '列表最大列宽度默认240dp',
value1: Pref.recommendCardWidth,
value2: Pref.smallCardWidth,
description1: '主页推荐流',
description2: '其他',
min: 150.0,
max: 500.0,
divisions: 35,
suffix: 'dp',
),
);
if (res != null) {
await GStorage.setting.putAll({
SettingBoxKey.recommendCardWidth: res.$1,
SettingBoxKey.smallCardWidth: res.$2,
});
SmartDialog.showToast('重启生效');
setState();
}
}
Future<void> _showUpPosDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<UpPanelPosition>(
context: context,
builder: (context) => SelectDialog<UpPanelPosition>(
title: '动态页UP主显示位置',
value: Pref.upPanelPosition,
values: UpPanelPosition.values.map((e) => (e, e.label)).toList(),
),
);
if (res != null) {
await GStorage.setting.put(SettingBoxKey.upPanelPosition, res.index);
SmartDialog.showToast('重启生效');
setState();
}
}
Future<void> _showDynBadgeDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<DynamicBadgeMode>(
context: context,
builder: (context) => SelectDialog<DynamicBadgeMode>(
title: '动态未读标记',
value: Pref.dynamicBadgeType,
values: DynamicBadgeMode.values.map((e) => (e, e.desc)).toList(),
),
);
if (res != null) {
final mainController = Get.find<MainController>()
..dynamicBadgeMode = DynamicBadgeMode.values[res.index];
if (mainController.dynamicBadgeMode != DynamicBadgeMode.hidden) {
mainController.getUnreadDynamic();
}
await GStorage.setting.put(
SettingBoxKey.dynamicBadgeMode,
res.index,
);
SmartDialog.showToast('设置成功');
setState();
}
}
Future<void> _showMsgBadgeDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<DynamicBadgeMode>(
context: context,
builder: (context) => SelectDialog<DynamicBadgeMode>(
title: '消息未读标记',
value: Pref.msgBadgeMode,
values: DynamicBadgeMode.values.map((e) => (e, e.desc)).toList(),
),
);
if (res != null) {
final mainController = Get.find<MainController>()
..msgBadgeMode = DynamicBadgeMode.values[res.index];
if (mainController.msgBadgeMode != DynamicBadgeMode.hidden) {
mainController.queryUnreadMsg(true);
} else {
mainController.msgUnReadCount.value = '';
}
await GStorage.setting.put(SettingBoxKey.msgBadgeMode, res.index);
SmartDialog.showToast('设置成功');
setState();
}
}
Future<void> _showMsgUnReadDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<Set<MsgUnReadType>>(
context: context,
builder: (context) => MultiSelectDialog<MsgUnReadType>(
title: '消息未读类型',
initValues: Pref.msgUnReadTypeV2,
values: {for (final i in MsgUnReadType.values) i: i.title},
),
);
if (res != null) {
final mainController = Get.find<MainController>()..msgUnReadTypes = res;
if (mainController.msgBadgeMode != DynamicBadgeMode.hidden) {
mainController.queryUnreadMsg();
}
await GStorage.setting.put(
SettingBoxKey.msgUnReadTypeV2,
res.map((item) => item.index).toList()..sort(),
);
SmartDialog.showToast('设置成功');
setState();
}
}
Future<void> _showThemeTypeDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<ThemeType>(
context: context,
builder: (context) => SelectDialog<ThemeType>(
title: '主题模式',
value: Pref.themeType,
values: ThemeType.values.map((e) => (e, e.desc)).toList(),
),
);
if (res != null) {
try {
Get.find<MineController>().themeType.value = res;
} catch (_) {}
GStorage.setting.put(SettingBoxKey.themeMode, res.index);
Get.changeThemeMode(ThemeUtils.themeMode = res.toThemeMode);
setState();
}
}
Future<void> _showDefHomeDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<NavigationBarType>(
context: context,
builder: (context) => SelectDialog<NavigationBarType>(
title: '首页启动页',
value: Pref.defaultHomePage,
values: NavigationBarType.values.map((e) => (e, e.label)).toList(),
),
);
if (res != null) {
await GStorage.setting.put(SettingBoxKey.defaultHomePage, res.index);
SmartDialog.showToast('设置成功,重启生效');
setState();
}
}
Future<void> _showBarHideTypeDialog(
BuildContext context,
VoidCallback setState,
) async {
final res = await showDialog<BarHideType>(
context: context,
builder: (context) => SelectDialog<BarHideType>(
title: '顶/底栏收起类型',
value: Pref.barHideType,
values: BarHideType.values.map((e) => (e, e.label)).toList(),
),
);
if (res != null) {
await GStorage.setting.put(SettingBoxKey.barHideType, res.index);
SmartDialog.showToast('重启生效');
setState();
}
}
NormalModel _useSSDModel() {
final file = File(path.join(appSupportDirPath, 'use_ssd'));
void onChanged(BuildContext context, VoidCallback setState) {
(file.existsSync() ? file.tryDel() : file.create()).whenComplete(() {
if (context.mounted) {
setState();
}
});
}
return NormalModel(
title: '使用SSDServer-Side Decoration',
leading: const Icon(Icons.web_asset),
onTap: onChanged,
getTrailing: (theme) => Builder(
builder: (context) => Transform.scale(
scale: 0.8,
alignment: .centerRight,
child: Switch(
value: file.existsSync(),
onChanged: (_) =>
onChanged(context, (context as Element).markNeedsBuild),
),
),
),
);
}