Files
flutter_server_box/lib/view/page/setting/entries/app.dart
GT610 9ac866644c ref:Refactor Settings UI and Fix Performance Issues (#1026)
* refactor(Settings page): Simplify the click handling logic of the cancel button

* fix(backup_service): Add a cancel button in the restore backup dialog

* refactor(Settings Page): Refactor the ordered list component and optimize state management

- Extract the logic for building list items into a separate method to improve maintainability
- Add animation effects to enhance the dragging experience
- Use PageStorageKey to maintain the scroll position
- Optimize the state management logic of the checkbox
- Add new contributors in github_id.dart

* fix: Add SafeArea to the settings page to prevent content from being obscured

Add SafeArea wrapping content in multiple settings pages to prevent content from being obscured by the navigation bar on certain devices, thereby enhancing user experience

* refactor: Extract file list retrieval method and optimize asynchronous loading of iOS settings page

Extract the `_getEntities` method from an inline function to a class member method to enhance code readability

Preload watch context and push token in the iOS settings page to avoid repeatedly creating Futures

* fix: Add a `key` attribute to the ChoiceChipX component to avoid rendering issues

* refactor(Settings page): Refactor the platform-related settings logic and merge the Android settings into the main page

Migrate the Android platform settings from a standalone page to the main settings page, and remove redundant Android settings page files

Adjust the platform setting logic, retaining only the special setting entry for the iOS platform

* build: Update fl_lib dependency to v1.0.363

* feat(Settings): Add persistent disable state for cards and virtual keys

Add persistent storage functionality for server detail cards and SSH virtual key disable status

Modify the logic of relevant pages to support the saving and restoration of disabled states

* refactor(setting): Simplify save logic and optimize file sorting performance

In the settings page, remove the unnecessary `enabledList` filtering and directly save the `_order` list

Optimize the sorting logic on the local file page by first retrieving the file status before proceeding with sorting

* fix: Optimize data filtering and backup service error handling on the settings page

Fix the data filtering logic in the settings page to only process key-value pairs with specific prefixes
Add error handling to the backup service, capture and display merge failure exceptions

* fix(Settings page): Fixed the issue where disabled items were not included in the order settings and asynchronously saved preference settings

Fix the issue where disabled items in the virtual keyboard and service details order settings are not included in the order list

Change the preference setting saving method to an asynchronous operation, and add a mounted check to prevent updating the state after the component is unmounted

* refactor: Optimize the reordering logic and remove redundant sorting methods

Narrow the scope of state updates in the reordering logic to only encompass the parts where data is actually modified

Remove the unused sorting methods in `_local.dart` to simplify the code

* refactor(view): Optimize the refresh logic of the local file page

Refactor the refresh method that directly calls setState into a unified _refresh method

Use the `_entitiesFuture` to cache the list of files to obtain results and avoid redundant calculations

* Update lib/view/page/storage/local.dart

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-25 18:43:57 +08:00

490 lines
15 KiB
Dart

part of '../entry.dart';
extension _App on _AppSettingsPageState {
Widget _buildApp() {
final androidSettings = isAndroid ? _buildAndroidSettings() : null;
final specific = _buildPlatformSetting();
final children = [
_buildLocale(),
_buildThemeMode(),
_buildAppColor(),
_buildCheckUpdate(),
_buildHomeTabs(),
PlatformPublicSettings.buildBioAuth,
if (androidSettings != null) androidSettings,
if (specific != null) specific,
_buildAppMore(),
];
return Column(children: children.map((e) => e.cardx).toList());
}
Widget _buildAndroidSettings() {
return ExpandTile(
leading: const Icon(Icons.phone_android),
title: Text('Android ${libL10n.setting}'),
children: [
_buildBgRun(),
_buildAndroidWidgetSharedPreference(),
],
);
}
Widget _buildBgRun() {
return ListTile(
title: TipText(l10n.bgRun, l10n.bgRunTip),
trailing: StoreSwitch(prop: Stores.setting.bgRun),
);
}
Widget _buildAndroidWidgetSharedPreference() {
return ListTile(
title: Text(l10n.homeWidgetUrlConfig),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () async {
const prefix = 'widget_';
final data = <String, String>{};
final keys = PrefStore.shared.keys();
for (final key in keys) {
if (!key.startsWith(prefix)) continue;
final val = PrefStore.shared.get<String>(key);
if (val != null) data[key] = val;
}
final result = await KvEditor.route.go(
context,
KvEditorArgs(data: data, prefix: prefix),
);
if (result != null) {
await _saveWidgetSP(result, data, prefix);
}
},
);
}
Future<void> _saveWidgetSP(Map<String, String> map, Map<String, String> old, String prefix) async {
try {
final keysDel = old.keys.toSet().difference(map.keys.toSet());
for (final key in keysDel) {
if (!key.startsWith(prefix)) continue;
await PrefStore.shared.remove(key);
}
for (final entry in map.entries) {
if (!entry.key.startsWith(prefix)) continue;
await PrefStore.shared.set(entry.key, entry.value);
}
if (mounted) context.showSnackBar(libL10n.success);
} catch (e) {
if (mounted) context.showSnackBar(e.toString());
}
}
Widget? _buildPlatformSetting() {
if (!isIOS) return null;
return ListTile(
leading: const Icon(MingCute.apple_fill),
title: Text('iOS ${libL10n.setting}'),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () => IosSettingsPage.route.go(context),
);
}
Widget _buildCheckUpdate() {
return ListTile(
leading: const Icon(Icons.update),
title: Text(libL10n.checkUpdate),
subtitle: ValBuilder(
listenable: AppUpdateIface.newestBuild,
builder: (val) {
String display;
if (val != null) {
if (val > BuildData.build) {
display = libL10n.versionHasUpdate(val);
} else {
display = libL10n.versionUpdated(BuildData.build);
}
} else {
display = libL10n.versionUnknownUpdate(BuildData.build);
}
return Text(display, style: UIs.textGrey);
},
),
onTap: () => Fns.throttle(
() => AppUpdateIface.doUpdate(
context: context,
build: BuildData.build,
url: Urls.updateCfg,
force: BuildMode.isDebug,
),
),
trailing: StoreSwitch(prop: _setting.autoCheckAppUpdate),
);
}
Widget _buildUpdateInterval() {
return ListTile(
title: Text(l10n.updateServerStatusInterval),
onTap: () async {
final val = await context.showPickSingleDialog(
title: libL10n.setting,
items: List.generate(10, (idx) => idx == 1 ? null : idx),
initial: _setting.serverStatusUpdateInterval.fetch(),
display: (p0) => p0 == 0 ? libL10n.manual : '$p0 ${l10n.second}',
);
if (val != null) {
_setting.serverStatusUpdateInterval.put(val);
}
},
trailing: ValBuilder(
listenable: _setting.serverStatusUpdateInterval.listenable(),
builder: (val) => Text('$val ${l10n.second}', style: UIs.text15),
),
);
}
Widget _buildAppColor() {
return ListTile(
leading: const Icon(Icons.colorize),
title: Text(libL10n.primaryColorSeed),
trailing: _setting.colorSeed.listenable().listenVal((_) {
return ClipOval(child: Container(color: UIs.primaryColor, height: 27, width: 27));
}),
onTap: () {
withTextFieldController((ctrl) async {
ctrl.text = Color(_setting.colorSeed.fetch()).toHex;
await context.showRoundDialog(
title: libL10n.primaryColorSeed,
child: StatefulBuilder(
builder: (context, setState) {
final children = <Widget>[
if (!isIOS)
DynamicColorBuilder(
builder: (light, dark) {
final supported = light != null || dark != null;
if (!supported) {
if (!_setting.useSystemPrimaryColor.fetch()) {
_setting.useSystemPrimaryColor.put(false);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {});
});
}
return const SizedBox.shrink();
}
return ListTile(
title: Text(l10n.followSystem),
trailing: StoreSwitch(
prop: _setting.useSystemPrimaryColor,
callback: (_) => setState(() {}),
),
);
},
),
];
if (!_setting.useSystemPrimaryColor.fetch()) {
children.add(
ColorPicker(
color: Color(_setting.colorSeed.fetch()),
onColorChanged: (c) => ctrl.text = c.toHex,
),
);
}
return Column(mainAxisSize: MainAxisSize.min, children: children);
},
),
actions: [
Btn.cancel(),
Btn.ok(onTap: () => _onSaveColor(ctrl.text)),
],
);
});
},
);
}
void _onSaveColor(String s) {
final color = s.fromColorHex;
if (color == null) {
context.showSnackBar(libL10n.fail);
return;
}
// Save the color seed to settings
_setting.colorSeed.put(color.value255);
// Only update UIs colors if we're not in system mode
if (!_setting.useSystemPrimaryColor.fetch()) {
UIs.primaryColor = color;
UIs.colorSeed = color;
}
RNodes.app.notify();
context.pop();
}
Widget _buildMaxRetry() {
return ValBuilder(
listenable: _setting.maxRetryCount.listenable(),
builder: (val) => ListTile(
title: Text(l10n.maxRetryCount),
onTap: () async {
final selected = await context.showPickSingleDialog(
title: l10n.maxRetryCount,
items: List.generate(10, (index) => index),
display: (p0) => '$p0 ${l10n.times}',
initial: val,
);
if (selected != null) {
_setting.maxRetryCount.put(selected);
}
},
trailing: Text('$val ${l10n.times}', style: UIs.text15),
),
);
}
Widget _buildThemeMode() {
// Issue #57
final len = ThemeMode.values.length;
return ListTile(
leading: const Icon(MingCute.moon_stars_fill),
title: Text(libL10n.themeMode),
onTap: () async {
final selected = await context.showPickSingleDialog(
title: libL10n.themeMode,
items: List.generate(len + 2, (index) => index),
display: (p0) => _buildThemeModeStr(p0),
initial: _setting.themeMode.fetch(),
);
if (selected != null) {
_setting.themeMode.put(selected);
RNodes.app.notify();
}
},
trailing: ValBuilder(
listenable: _setting.themeMode.listenable(),
builder: (val) => Text(_buildThemeModeStr(val), style: UIs.text15),
),
);
}
String _buildThemeModeStr(int n) {
switch (n) {
case 1:
return libL10n.bright;
case 2:
return libL10n.dark;
case 3:
return 'AMOLED';
case 4:
return '${libL10n.auto} AMOLED';
default:
return libL10n.auto;
}
}
Widget _buildLocale() {
return ListTile(
leading: const Icon(IonIcons.language),
title: Text(libL10n.language),
onTap: () async {
final selected = await context.showPickSingleDialog(
title: libL10n.language,
items: AppLocalizations.supportedLocales,
display: (p0) => p0.nativeName,
initial: _setting.locale.fetch().toLocale,
);
if (selected != null) {
_setting.locale.put(selected.code);
context.pop();
RNodes.app.notify();
}
},
trailing: ListenBuilder(
listenable: _setting.locale.listenable(),
builder: () => Text(context.localeNativeName, style: UIs.text15),
),
);
}
Widget _buildAppMore() {
return ExpandTile(
leading: const Icon(MingCute.more_3_fill),
title: Text(l10n.more),
initiallyExpanded: false,
children: [
_buildBeta(),
if (isMobile) _buildWakeLock(),
_buildCollapseUI(),
_buildCupertinoRoute(),
if (isDesktop) _buildHideTitleBar(),
_buildEditRawSettings(),
],
);
}
Widget _buildBeta() {
return ListTile(
title: TipText('Beta Program', l10n.acceptBeta),
trailing: StoreSwitch(prop: _setting.betaTest),
);
}
Widget _buildWakeLock() {
return ListTile(
title: Text(l10n.wakeLock),
trailing: StoreSwitch(prop: _setting.generalWakeLock),
);
}
Widget _buildCollapseUI() {
return ListTile(
title: TipText('UI ${libL10n.fold}', l10n.collapseUITip),
trailing: StoreSwitch(prop: _setting.collapseUIDefault),
);
}
Widget _buildCupertinoRoute() {
return ListTile(
title: Text('Cupertino ${l10n.route}'),
trailing: StoreSwitch(prop: _setting.cupertinoRoute),
);
}
Widget _buildHideTitleBar() {
return ListTile(
title: Text(l10n.hideTitleBar),
trailing: StoreSwitch(prop: _setting.hideTitleBar),
);
}
Widget _buildHomeTabs() {
return ListTile(
leading: const Icon(Icons.tab),
title: Text(l10n.homeTabs),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () {
HomeTabsConfigPage.route.go(context);
},
);
}
Widget _buildEditRawSettings() {
return ListTile(
title: const Text('(Dev) Edit raw json'),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: _editRawSettings,
);
}
Future<void> _editRawSettings() async {
final rawMap = Stores.setting.getAllMap(includeInternalKeys: true);
final map = Map<String, Object?>.from(rawMap);
final initialKeys = Set<String>.from(map.keys);
Map<String, Object?> mapForEditor = map;
String? encryptedKey;
String? passwordUsed;
Future<String?> resolvePassword() async {
final saved = await _setting.backupasswd.read();
if (saved?.isNotEmpty == true) return saved;
final backupPwd = await SecureStoreProps.bakPwd.read();
if (backupPwd?.isNotEmpty == true) return backupPwd;
final controller = TextEditingController();
try {
final result = await context.showRoundDialog<String>(
title: libL10n.pwd,
child: Input(
controller: controller,
label: libL10n.pwd,
obscureText: true,
onSubmitted: (_) => context.pop(controller.text.trim()),
),
actions: [
TextButton(onPressed: () => context.pop(null), child: Text(libL10n.cancel)),
TextButton(onPressed: () => context.pop(controller.text.trim()), child: Text(libL10n.ok)),
],
);
return result?.trim();
} finally {
controller.dispose();
}
}
for (final entry in map.entries) {
final value = entry.value;
if (value is String && Cryptor.isEncrypted(value)) {
final password = await resolvePassword();
if (password == null || password.isEmpty) {
context.showSnackBar(libL10n.cancel);
return;
}
try {
final decrypted = Cryptor.decrypt(value, password);
final decoded = json.decode(decrypted);
if (decoded is Map<String, dynamic>) {
mapForEditor = Map<String, Object?>.from(decoded);
encryptedKey = entry.key;
passwordUsed = password;
break;
} else {
context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid));
return;
}
} catch (e, stack) {
final msg = e.toString().contains('Failed to decrypt') || e.toString().contains('incorrect password')
? l10n.backupPasswordWrong
: '${libL10n.error}:\n$e';
context.showRoundDialog(title: libL10n.fail, child: Text(msg));
Loggers.app.warning('Decrypt raw settings failed', e, stack);
return;
}
}
}
void onSave(EditorPageRet ret) {
if (ret.typ != EditorPageRetType.text) {
context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid));
return;
}
try {
final newSettings = json.decode(ret.val) as Map<String, dynamic>;
if (encryptedKey != null) {
final pwd = passwordUsed;
if (pwd == null || pwd.isEmpty) {
context.showRoundDialog(title: libL10n.fail, child: Text(l10n.invalid));
return;
}
final encrypted = Cryptor.encrypt(json.encode(newSettings), pwd);
Stores.setting.box.put(encryptedKey, encrypted);
} else {
Stores.setting.box.putAll(newSettings);
final newKeys = newSettings.keys.toSet();
final removedKeys = initialKeys.where((e) => !newKeys.contains(e));
for (final key in removedKeys) {
Stores.setting.box.delete(key);
}
}
} catch (e, trace) {
context.showRoundDialog(title: libL10n.error, child: Text('${l10n.save}:\n$e'));
Loggers.app.warning('Update json settings failed', e, trace);
}
}
/// Encode [map] to String with indent `\t`
final text = jsonIndentEncoder.convert(mapForEditor);
await EditorPage.route.go(
context,
args: EditorPageArgs(
text: text,
lang: ProgLang.json,
title: libL10n.setting,
onSave: onSave,
closeAfterSave: SettingStore.instance.closeAfterSave.fetch(),
softWrap: SettingStore.instance.editorSoftWrap.fetch(),
enableHighlight: SettingStore.instance.editorHighlight.fetch(),
),
);
}
}