import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:equations/equations.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/native_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shizuku_apk_installer/shizuku_apk_installer.dart'; import 'package:url_launcher/url_launcher_string.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { List updateIntervalNodes = [ 15, 30, 60, 120, 180, 360, 720, 1440, 4320, 10080, 20160, 43200 ]; int updateInterval = 0; late SplineInterpolation updateIntervalInterpolator; // 🤓 String updateIntervalLabel = tr('neverManualOnly'); bool showIntervalLabel = true; final Map, String> colorsNameMap = , String>{ ColorTools.createPrimarySwatch(obtainiumThemeColor): 'Obtainium' }; void initUpdateIntervalInterpolator() { List nodes = []; for (final (index, element) in updateIntervalNodes.indexed) { nodes.add( InterpolationNode(x: index.toDouble() + 1, y: element.toDouble())); } updateIntervalInterpolator = SplineInterpolation(nodes: nodes); } void processIntervalSliderValue(double val) { if (val < 0.5) { updateInterval = 0; updateIntervalLabel = tr('neverManualOnly'); return; } int valInterpolated = 0; if (val < 1) { valInterpolated = 15; } else { valInterpolated = updateIntervalInterpolator.compute(val).round(); } if (valInterpolated < 60) { updateInterval = valInterpolated; updateIntervalLabel = plural('minute', valInterpolated); } else if (valInterpolated < 8 * 60) { int valRounded = (valInterpolated / 15).floor() * 15; updateInterval = valRounded; updateIntervalLabel = plural('hour', valRounded ~/ 60); int mins = valRounded % 60; if (mins != 0) updateIntervalLabel += " ${plural('minute', mins)}"; } else if (valInterpolated < 24 * 60) { int valRounded = (valInterpolated / 30).floor() * 30; updateInterval = valRounded; updateIntervalLabel = plural('hour', valRounded / 60); } else if (valInterpolated < 7 * 24 * 60) { int valRounded = (valInterpolated / (12 * 60)).floor() * 12 * 60; updateInterval = valRounded; updateIntervalLabel = plural('day', valRounded / (24 * 60)); } else { int valRounded = (valInterpolated / (24 * 60)).floor() * 24 * 60; updateInterval = valRounded; updateIntervalLabel = plural('day', valRounded ~/ (24 * 60)); } } @override Widget build(BuildContext context) { SettingsProvider settingsProvider = context.watch(); SourceProvider sourceProvider = SourceProvider(); if (settingsProvider.prefs == null) settingsProvider.initializeSettings(); initUpdateIntervalInterpolator(); processIntervalSliderValue(settingsProvider.updateIntervalSliderVal); var followSystemThemeExplanation = FutureBuilder( builder: (ctx, val) { return ((val.data?.version.sdkInt ?? 30) < 29) ? Text(tr('followSystemThemeExplanation'), style: Theme.of(context).textTheme.labelSmall) : const SizedBox.shrink(); }, future: DeviceInfoPlugin().androidInfo); Future colorPickerDialog() async { return ColorPicker( color: settingsProvider.themeColor, onColorChanged: (Color color) => setState(() => settingsProvider.themeColor = color), actionButtons: const ColorPickerActionButtons( okButton: true, closeButton: true, dialogActionButtons: false, ), pickersEnabled: const { ColorPickerType.both: false, ColorPickerType.primary: false, ColorPickerType.accent: false, ColorPickerType.bw: false, ColorPickerType.custom: true, ColorPickerType.wheel: true, }, pickerTypeLabels: { ColorPickerType.custom: tr('standard'), ColorPickerType.wheel: tr('custom') }, title: Text(tr('selectX', args: [tr('colour')]), style: Theme.of(context).textTheme.titleLarge), wheelDiameter: 192, wheelSquareBorderRadius: 32, width: 48, height: 48, borderRadius: 24, spacing: 8, runSpacing: 8, enableShadesSelection: false, customColorSwatchesAndNames: colorsNameMap, showMaterialName: true, showColorName: true, materialNameTextStyle: Theme.of(context).textTheme.bodySmall, colorNameTextStyle: Theme.of(context).textTheme.bodySmall, copyPasteBehavior: const ColorPickerCopyPasteBehavior(longPressMenu: true), ).showPickerDialog( context, transitionBuilder: (BuildContext context, Animation a1, Animation a2, Widget widget) { final double curvedValue = Curves.easeInCubic.transform(a1.value); return Transform( alignment: Alignment.center, transform: Matrix4.diagonal3Values(curvedValue, curvedValue, 1), child: Opacity(opacity: curvedValue, child: widget), ); }, transitionDuration: const Duration(milliseconds: 250), ); } var colorPicker = ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text(tr('selectX', args: [tr('colour')])), subtitle: Text( "${ColorTools.nameThatColor(settingsProvider.themeColor)} " "(${ColorTools.materialNameAndCode(settingsProvider.themeColor, colorSwatchNameMap: colorsNameMap)})"), trailing: ColorIndicator( width: 40, height: 40, borderRadius: 20, color: settingsProvider.themeColor, onSelectFocus: false, onSelect: () async { final Color colorBeforeDialog = settingsProvider.themeColor; if (!(await colorPickerDialog())) { setState(() { settingsProvider.themeColor = colorBeforeDialog; }); } })); var useMaterialThemeSwitch = FutureBuilder( builder: (ctx, val) { return ((val.data?.version.sdkInt ?? 0) >= 31) ? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('useMaterialYou'))), Switch( value: settingsProvider.useMaterialYou, onChanged: (value) { settingsProvider.useMaterialYou = value; }) ], ) : const SizedBox.shrink(); }, future: DeviceInfoPlugin().androidInfo); var sortDropdown = DropdownButtonFormField( isExpanded: true, decoration: InputDecoration(labelText: tr('appSortBy')), value: settingsProvider.sortColumn, items: [ DropdownMenuItem( value: SortColumnSettings.authorName, child: Text(tr('authorName')), ), DropdownMenuItem( value: SortColumnSettings.nameAuthor, child: Text(tr('nameAuthor')), ), DropdownMenuItem( value: SortColumnSettings.added, child: Text(tr('asAdded')), ), DropdownMenuItem( value: SortColumnSettings.releaseDate, child: Text(tr('releaseDate')), ) ], onChanged: (value) { if (value != null) { settingsProvider.sortColumn = value; } }); var orderDropdown = DropdownButtonFormField( isExpanded: true, decoration: InputDecoration(labelText: tr('appSortOrder')), value: settingsProvider.sortOrder, items: [ DropdownMenuItem( value: SortOrderSettings.ascending, child: Text(tr('ascending')), ), DropdownMenuItem( value: SortOrderSettings.descending, child: Text(tr('descending')), ), ], onChanged: (value) { if (value != null) { settingsProvider.sortOrder = value; } }); var localeDropdown = DropdownButtonFormField( decoration: InputDecoration(labelText: tr('language')), value: settingsProvider.forcedLocale, items: [ DropdownMenuItem( value: null, child: Text(tr('followSystem')), ), ...supportedLocales.map((e) => DropdownMenuItem( value: e.key.toLanguageTag(), child: Text(e.value), )) ], onChanged: (value) { settingsProvider.forcedLocale = value; if (value != null) { context.setLocale(Locale(value)); } else { settingsProvider.resetLocaleSafe(context); } }); var intervalSlider = Slider( value: settingsProvider.updateIntervalSliderVal, max: updateIntervalNodes.length.toDouble(), divisions: updateIntervalNodes.length * 20, label: updateIntervalLabel, onChanged: (double value) { setState(() { settingsProvider.updateIntervalSliderVal = value; processIntervalSliderValue(value); }); }, onChangeStart: (double value) { setState(() { showIntervalLabel = false; }); }, onChangeEnd: (double value) { setState(() { showIntervalLabel = true; settingsProvider.updateInterval = updateInterval; }); }, ); var sourceSpecificFields = sourceProvider.sources.map((e) { if (e.sourceConfigSettingFormItems.isNotEmpty) { return GeneratedForm( items: e.sourceConfigSettingFormItems.map((e) { e.defaultValue = settingsProvider.getSettingString(e.key); return [e]; }).toList(), onValueChanges: (values, valid, isBuilding) { if (valid && !isBuilding) { values.forEach((key, value) { settingsProvider.setSettingString(key, value); }); } }); } else { return Container(); } }); const height8 = SizedBox( height: 8, ); const height16 = SizedBox( height: 16, ); const height32 = SizedBox( height: 32, ); return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ CustomAppBar(title: tr('settings')), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: settingsProvider.prefs == null ? const SizedBox() : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( tr('updates'), style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), //intervalDropdown, height16, if (showIntervalLabel) SizedBox( child: Text( "${tr('bgUpdateCheckInterval')}: $updateIntervalLabel")) else const SizedBox(height: 16), intervalSlider, FutureBuilder( builder: (ctx, val) { return (settingsProvider.updateInterval > 0) && (((val.data?.version.sdkInt ?? 0) >= 30) || settingsProvider.useShizuku) ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment .spaceBetween, children: [ Flexible( child: Text(tr( 'enableBackgroundUpdates'))), Switch( value: settingsProvider .enableBackgroundUpdates, onChanged: (value) { settingsProvider .enableBackgroundUpdates = value; }) ], ), height8, Text(tr('backgroundUpdateReqsExplanation'), style: Theme.of(context) .textTheme .labelSmall), Text(tr('backgroundUpdateLimitsExplanation'), style: Theme.of(context) .textTheme .labelSmall), height8, if (settingsProvider .enableBackgroundUpdates) Column( children: [ height16, Row( mainAxisAlignment: MainAxisAlignment .spaceBetween, children: [ Flexible( child: Text(tr( 'bgUpdatesOnWiFiOnly'))), Switch( value: settingsProvider .bgUpdatesOnWiFiOnly, onChanged: (value) { settingsProvider .bgUpdatesOnWiFiOnly = value; }) ], ), ], ), ], ) : const SizedBox.shrink(); }, future: DeviceInfoPlugin().androidInfo), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('checkOnStart'))), Switch( value: settingsProvider.checkOnStart, onChanged: (value) { settingsProvider.checkOnStart = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('checkUpdateOnDetailPage'))), Switch( value: settingsProvider .checkUpdateOnDetailPage, onChanged: (value) { settingsProvider.checkUpdateOnDetailPage = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr( 'onlyCheckInstalledOrTrackOnlyApps'))), Switch( value: settingsProvider .onlyCheckInstalledOrTrackOnlyApps, onChanged: (value) { settingsProvider .onlyCheckInstalledOrTrackOnlyApps = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('removeOnExternalUninstall'))), Switch( value: settingsProvider .removeOnExternalUninstall, onChanged: (value) { settingsProvider .removeOnExternalUninstall = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('parallelDownloads'))), Switch( value: settingsProvider.parallelDownloads, onChanged: (value) { settingsProvider.parallelDownloads = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(tr( 'beforeNewInstallsShareToAppVerifier')), GestureDetector( onTap: () { launchUrlString( 'https://github.com/soupslurpr/AppVerifier', mode: LaunchMode .externalApplication); }, child: Text( tr('about'), style: const TextStyle( decoration: TextDecoration.underline, fontSize: 12), )), ], )), Switch( value: settingsProvider .beforeNewInstallsShareToAppVerifier, onChanged: (value) { settingsProvider .beforeNewInstallsShareToAppVerifier = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('useShizuku'))), Switch( value: settingsProvider.useShizuku, onChanged: (useShizuku) { if (useShizuku) { ShizukuApkInstaller.checkPermission() .then((resCode) { settingsProvider.useShizuku = resCode!.startsWith('granted'); switch (resCode) { case 'binder_not_found': showError( ObtainiumError(tr( 'shizukuBinderNotFound')), context); case 'old_shizuku': showError( ObtainiumError( tr('shizukuOld')), context); case 'old_android_with_adb': showError( ObtainiumError(tr( 'shizukuOldAndroidWithADB')), context); case 'denied': showError( ObtainiumError( tr('cancelled')), context); } }); } else { settingsProvider.useShizuku = false; } }) ], ), height32, Text( tr('sourceSpecific'), style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), ...sourceSpecificFields, height32, Text( tr('appearance'), style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), DropdownButtonFormField( decoration: InputDecoration(labelText: tr('theme')), value: settingsProvider.theme, items: [ DropdownMenuItem( value: ThemeSettings.system, child: Text(tr('followSystem')), ), DropdownMenuItem( value: ThemeSettings.light, child: Text(tr('light')), ), DropdownMenuItem( value: ThemeSettings.dark, child: Text(tr('dark')), ) ], onChanged: (value) { if (value != null) { settingsProvider.theme = value; } }), height8, if (settingsProvider.theme == ThemeSettings.system) followSystemThemeExplanation, height16, if (settingsProvider.theme != ThemeSettings.light) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('useBlackTheme'))), Switch( value: settingsProvider.useBlackTheme, onChanged: (value) { settingsProvider.useBlackTheme = value; }) ]), height8, useMaterialThemeSwitch, if (!settingsProvider.useMaterialYou) colorPicker, Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: sortDropdown), const SizedBox( width: 16, ), Expanded(child: orderDropdown), ], ), height16, localeDropdown, FutureBuilder( builder: (ctx, val) { return (val.data?.version.sdkInt ?? 0) >= 34 ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ height16, Row( mainAxisAlignment: MainAxisAlignment .spaceBetween, children: [ Flexible( child: Text(tr( 'useSystemFont'))), Switch( value: settingsProvider .useSystemFont, onChanged: (useSystemFont) { if (useSystemFont) { NativeFeatures .loadSystemFont() .then((val) { settingsProvider .useSystemFont = true; }); } else { settingsProvider .useSystemFont = false; } }) ]) ]) : const SizedBox.shrink(); }, future: DeviceInfoPlugin().androidInfo), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('showWebInAppView'))), Switch( value: settingsProvider.showAppWebpage, onChanged: (value) { settingsProvider.showAppWebpage = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('pinUpdates'))), Switch( value: settingsProvider.pinUpdates, onChanged: (value) { settingsProvider.pinUpdates = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( tr('moveNonInstalledAppsToBottom'))), Switch( value: settingsProvider.buryNonInstalled, onChanged: (value) { settingsProvider.buryNonInstalled = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(tr('groupByCategory'))), Switch( value: settingsProvider.groupByCategory, onChanged: (value) { settingsProvider.groupByCategory = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('dontShowTrackOnlyWarnings'))), Switch( value: settingsProvider.hideTrackOnlyWarning, onChanged: (value) { settingsProvider.hideTrackOnlyWarning = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('dontShowAPKOriginWarnings'))), Switch( value: settingsProvider.hideAPKOriginWarning, onChanged: (value) { settingsProvider.hideAPKOriginWarning = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('disablePageTransitions'))), Switch( value: settingsProvider.disablePageTransitions, onChanged: (value) { settingsProvider.disablePageTransitions = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('reversePageTransitions'))), Switch( value: settingsProvider.reversePageTransitions, onChanged: settingsProvider .disablePageTransitions ? null : (value) { settingsProvider .reversePageTransitions = value; }) ], ), height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text(tr('highlightTouchTargets'))), Switch( value: settingsProvider.highlightTouchTargets, onChanged: (value) { settingsProvider.highlightTouchTargets = value; }) ], ), height32, Text( tr('categories'), style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary), ), height16, const CategoryEditorSelector( showLabelWhenNotEmpty: false, ) ], ))), SliverToBoxAdapter( child: Column( children: [ const Divider( height: 32, ), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( onPressed: () { launchUrlString(settingsProvider.sourceUrl, mode: LaunchMode.externalApplication); }, icon: const Icon(Icons.code), tooltip: tr('appSource'), ), IconButton( onPressed: () { launchUrlString('${settingsProvider.sourceUrl}/wiki', mode: LaunchMode.externalApplication); }, icon: const Icon(Icons.help_outline_rounded), tooltip: tr('wiki'), ), IconButton( onPressed: () { launchUrlString('https://apps.obtainium.imranr.dev/', mode: LaunchMode.externalApplication); }, icon: const Icon(Icons.apps_rounded), tooltip: tr('crowdsourcedConfigsLabel'), ), IconButton( onPressed: () { context.read().get().then((logs) { if (logs.isEmpty) { showMessage( ObtainiumError(tr('noLogs')), context); } else { showDialog( context: context, builder: (BuildContext ctx) { return const LogsDialog(); }); } }); }, icon: const Icon(Icons.bug_report_outlined), tooltip: tr('appLogs')) ], ), const SizedBox( height: 16, ) ], ), ) ])); } } class LogsDialog extends StatefulWidget { const LogsDialog({super.key}); @override State createState() => _LogsDialogState(); } class _LogsDialogState extends State { String? logString; List days = [7, 5, 4, 3, 2, 1]; @override Widget build(BuildContext context) { var logsProvider = context.read(); void filterLogs(int days) { logsProvider .get(after: DateTime.now().subtract(Duration(days: days))) .then((value) { setState(() { String l = value.map((e) => e.toString()).join('\n\n'); logString = l.isNotEmpty ? l : tr('noLogs'); }); }); } if (logString == null) { filterLogs(days.first); } return AlertDialog( scrollable: true, title: Text(tr('appLogs')), content: Column( children: [ DropdownButtonFormField( value: days.first, items: days .map((e) => DropdownMenuItem( value: e, child: Text(plural('day', e)), )) .toList(), onChanged: (d) { filterLogs(d ?? 7); }), const SizedBox( height: 32, ), Text(logString ?? '') ], ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text(tr('close'))), TextButton( onPressed: () { Share.share(logString ?? '', subject: tr('appLogs')); Navigator.of(context).pop(); }, child: Text(tr('share'))) ], ); } } class CategoryEditorSelector extends StatefulWidget { final void Function(List categories)? onSelected; final bool singleSelect; final Set preselected; final WrapAlignment alignment; final bool showLabelWhenNotEmpty; const CategoryEditorSelector( {super.key, this.onSelected, this.singleSelect = false, this.preselected = const {}, this.alignment = WrapAlignment.start, this.showLabelWhenNotEmpty = true}); @override State createState() => _CategoryEditorSelectorState(); } class _CategoryEditorSelectorState extends State { Map> storedValues = {}; @override Widget build(BuildContext context) { var settingsProvider = context.watch(); var appsProvider = context.watch(); storedValues = settingsProvider.categories.map((key, value) => MapEntry( key, MapEntry(value, storedValues[key]?.value ?? widget.preselected.contains(key)))); return GeneratedForm( items: [ [ GeneratedFormTagInput('categories', label: tr('categories'), emptyMessage: tr('noCategories'), defaultValue: storedValues, alignment: widget.alignment, deleteConfirmationMessage: MapEntry( tr('deleteCategoriesQuestion'), tr('categoryDeleteWarning')), singleSelect: widget.singleSelect, showLabelWhenNotEmpty: widget.showLabelWhenNotEmpty) ] ], onValueChanges: ((values, valid, isBuilding) { if (!isBuilding) { storedValues = values['categories'] as Map>; settingsProvider.setCategories( storedValues.map((key, value) => MapEntry(key, value.key)), appsProvider: appsProvider); if (widget.onSelected != null) { widget.onSelected!(storedValues.keys .where((k) => storedValues[k]!.value) .toList()); } } })); } }